commit 083402a548f071d911639a46f57d22c7b2c71151 Author: Mortdecai Date: Tue Apr 7 19:54:54 2026 -0400 init: pristine aerc 0.20.0 source diff --git a/.builds/alpine-edge.yml b/.builds/alpine-edge.yml new file mode 100644 index 0000000..eb6e3d5 --- /dev/null +++ b/.builds/alpine-edge.yml @@ -0,0 +1,26 @@ +--- +image: alpine/edge +packages: + - curl + - go + - gnupg + - notmuch-dev + - py3-codespell + - scdoc + - valgrind +sources: + - "https://git.sr.ht/~rjarry/aerc" +environment: + DESTDIR: ./out + GOFLAGS: "-tags=notmuch" + CC: gcc + FILTERS_TEST_BIN_PREFIX: valgrind --leak-check=full --error-exitcode=1 +tasks: + - validate: | + gmake -C aerc validate + - install: | + gmake -C aerc install checkinstall + - ancient-go-version: | + curl -O https://dl-cdn.alpinelinux.org/alpine/v3.19/community/x86_64/go-1.21.10-r0.apk + sudo apk add ./go-1.21.10-r0.apk + gmake -C aerc clean all diff --git a/.builds/openbsd.yml b/.builds/openbsd.yml new file mode 100644 index 0000000..46be4f1 --- /dev/null +++ b/.builds/openbsd.yml @@ -0,0 +1,17 @@ +--- +image: openbsd/latest +packages: + - base64 + - gmake + - gnupg + - go + - scdoc +sources: + - "https://git.sr.ht/~rjarry/aerc" +environment: + DESTDIR: ./out +tasks: + - build: | + gmake -C aerc + - install: | + gmake -C aerc install checkinstall diff --git a/.codespellrc b/.codespellrc new file mode 100644 index 0000000..0546f6a --- /dev/null +++ b/.codespellrc @@ -0,0 +1,27 @@ +# ex: ft=dosini + +[codespell] +quiet-level = 35 +skip = + *.1, + *.5, + *.7, + *log*, + *.log*, + .changelog.md, + .env, + contrib/aerc.desktop, + filters/vectors/*, + tags, +ignore-words-list = + DeVault, + Fo, + THRID, + fo, + fpr, + froms, + localY, + ment, + struc, + te, + thrid, diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..7c5d55d --- /dev/null +++ b/.editorconfig @@ -0,0 +1,19 @@ +# https://editorconfig.org/ + +root = true + +[*] +end_of_line = lf +insert_final_newline = true +charset = utf-8 + +[**.go] +indent_style = tab +max_line_length = 80 +tab_width = 8 + +[*Makefile] +indent_style = tab + +[**.scd] +indent_style = tab diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml new file mode 100644 index 0000000..3e355a2 --- /dev/null +++ b/.github/workflows/macos.yml @@ -0,0 +1,31 @@ +--- +on: push + +jobs: + macos: + runs-on: macos-latest + strategy: + matrix: + go: + - '1.21' + - '1.22' + env: + DESTDIR: ./out + GOFLAGS: -tags=notmuch + name: MacOS Go ${{ matrix.go }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go }} + check-latest: true + - run: brew install gnupg notmuch scdoc + - run: | + cat >> "$GITHUB_ENV" < +Andrew Jeffrey +Andrew Jeffrey Andrew Jeffery +Andrew Jeffrey Jeffas +Bor Grošelj Simić +Christopher Vittal +Christopher Vittal Chris Vittal +Drew DeVault +Inwit +JD +Kalyan Sriram +Kevin Kuehler +Kevin Kuehler +Leszek Cimała +Moritz Poldrack +Peter Lamby +Simon Ser +Thomas Böhler +Tim Culverhouse +Wagner Riffel diff --git a/.pc/.quilt_patches b/.pc/.quilt_patches new file mode 100644 index 0000000..6857a8d --- /dev/null +++ b/.pc/.quilt_patches @@ -0,0 +1 @@ +debian/patches diff --git a/.pc/.quilt_series b/.pc/.quilt_series new file mode 100644 index 0000000..c206706 --- /dev/null +++ b/.pc/.quilt_series @@ -0,0 +1 @@ +series diff --git a/.pc/.version b/.pc/.version new file mode 100644 index 0000000..0cfbf08 --- /dev/null +++ b/.pc/.version @@ -0,0 +1 @@ +2 diff --git a/.pc/applied-patches b/.pc/applied-patches new file mode 100644 index 0000000..a7c8c4e --- /dev/null +++ b/.pc/applied-patches @@ -0,0 +1,2 @@ +fix-blhc.patch +fix-temp-file-creation.patch diff --git a/.pc/fix-blhc.patch/GNUmakefile b/.pc/fix-blhc.patch/GNUmakefile new file mode 100644 index 0000000..fe52434 --- /dev/null +++ b/.pc/fix-blhc.patch/GNUmakefile @@ -0,0 +1,214 @@ +# variables that can be changed by users +# +VERSION ?= $(shell git describe --long --abbrev=12 --tags --dirty 2>/dev/null || echo 0.20.0) +DATE ?= $(shell date +%Y-%m-%d) +PREFIX ?= /usr/local +BINDIR ?= $(PREFIX)/bin +SHAREDIR ?= $(PREFIX)/share/aerc +LIBEXECDIR ?= $(PREFIX)/libexec/aerc +MANDIR ?= $(PREFIX)/share/man +GO ?= go +INSTALL ?= install +CP ?= cp +GOFLAGS ?= $(shell contrib/goflags.sh) +BUILD_OPTS ?= -trimpath +GO_LDFLAGS := +GO_LDFLAGS += -X main.Version=$(VERSION) +GO_LDFLAGS += -X main.Date=$(DATE) +GO_LDFLAGS += -X git.sr.ht/~rjarry/aerc/config.shareDir=$(SHAREDIR) +GO_LDFLAGS += -X git.sr.ht/~rjarry/aerc/config.libexecDir=$(LIBEXECDIR) +GO_LDFLAGS += $(GO_EXTRA_LDFLAGS) +CC ?= cc +CFLAGS ?= -O2 -g + +# internal variables used for automatic rules generation with macros +gosrc = $(shell find * -type f -name '*.go') go.mod go.sum +man1 = $(subst .scd,,$(notdir $(wildcard doc/*.1.scd))) +man5 = $(subst .scd,,$(notdir $(wildcard doc/*.5.scd))) +man7 = $(subst .scd,,$(notdir $(wildcard doc/*.7.scd))) +docs = $(man1) $(man5) $(man7) +cfilters = $(subst .c,,$(notdir $(wildcard filters/*.c))) +filters = $(filter-out filters/vectors filters/test.sh filters/%.c,$(wildcard filters/*)) +gofumpt_tag = v0.7.0 + +# Dependencies are added dynamically to the "all" rule with macros +.PHONY: all +all: aerc + @: + +aerc: $(gosrc) + $(GO) build $(BUILD_OPTS) $(GOFLAGS) -ldflags "$(GO_LDFLAGS)" -o aerc + +.PHONY: dev +dev: + $(RM) aerc + $(MAKE) --no-print-directory aerc BUILD_OPTS="-trimpath -race" + GORACE="log_path=race.log strip_path_prefix=git.sr.ht/~rjarry/aerc/" ./aerc + +.PHONY: fmt +fmt: + $(GO) run mvdan.cc/gofumpt@$(gofumpt_tag) -w . + +.PHONY: lint +lint: + @contrib/check-whitespace `git ls-files ':!:filters/vectors'` && \ + echo white space ok. + @contrib/check-docs && echo docs ok. + @$(GO) run mvdan.cc/gofumpt@$(gofumpt_tag) -d . | grep ^ \ + && echo The above files need to be formatted, please run make fmt && exit 1 \ + || echo all files formatted. + codespell * + $(GO) run github.com/golangci/golangci-lint/cmd/golangci-lint@v1.62.2 run \ + $$(echo $(GOFLAGS) | sed s/-tags=/--build-tags=/) + $(GO) run $(GOFLAGS) contrib/linters.go ./... + +.PHONY: vulncheck +vulncheck: + $(GO) run golang.org/x/vuln/cmd/govulncheck@latest ./... + +.PHONY: tests +tests: $(cfilters) + $(GO) test $(GOFLAGS) ./... + filters/test.sh + +.PHONY: debug +debug: aerc.debug + @echo 'Run `./aerc.debug` and use this command in another terminal to attach a debugger:' + @echo ' dlv attach $$(pidof aerc.debug)' + +aerc.debug: $(gosrc) + $(GO) build $(subst -trimpath,,$(GOFLAGS)) -gcflags=all="-N -l" -ldflags="$(GO_LDFLAGS)" -o aerc.debug + +.PHONY: doc +doc: $(docs) + @: + +.PHONY: clean +clean: + $(RM) $(docs) aerc $(cfilters) + +# Dependencies are added dynamically to the "install" rule with macros +.PHONY: install +install: + @: + +.PHONY: checkinstall +checkinstall: + $(DESTDIR)$(BINDIR)/aerc -v + for m in $(man1); do test -e $(DESTDIR)$(MANDIR)/man1/$$m || exit; done + for m in $(man5); do test -e $(DESTDIR)$(MANDIR)/man5/$$m || exit; done + for m in $(man7); do test -e $(DESTDIR)$(MANDIR)/man7/$$m || exit; done + +.PHONY: uninstall +uninstall: + @echo $(installed) | tr ' ' '\n' | sort -ru | while read -r f; do \ + echo rm -f $$f && rm -f $$f || exit; \ + done + @echo $(dirs) | tr ' ' '\n' | sort -ru | while read -r d; do \ + if [ -d $$d ] && ! ls -Aq1 $$d | grep -q .; then \ + echo rmdir $$d && rmdir $$d || exit; \ + fi; \ + done + +.PHONY: gitconfig +gitconfig: + git config format.subjectPrefix "PATCH aerc" + git config sendemail.to "~rjarry/aerc-devel@lists.sr.ht" + git config format.notes true + git config notes.rewriteRef refs/notes/commits + git config notes.rewriteMode concatenate + @mkdir -p .git/hooks + @rm -f .git/hooks/commit-msg* + ln -s ../../contrib/commit-msg .git/hooks/commit-msg + @rm -f .git/hooks/sendemail-validate* + @if grep -q GIT_SENDEMAIL_FILE_COUNTER `git --exec-path`/git-send-email 2>/dev/null; then \ + set -xe; \ + ln -s ../../contrib/sendemail-validate .git/hooks/sendemail-validate && \ + git config sendemail.validate true; \ + fi + +.PHONY: check-patches +check-patches: + @contrib/check-patches origin/master.. + +.PHONY: validate +validate: CFLAGS = -Wall -Wextra -Wconversion -Werror -Wformat-security -Wstack-protector -Wpedantic -Wmissing-prototypes +validate: all tests lint check-patches + +# Generate build and install rules for one man page +# +# $1: man page name (e.g: aerc.1) +# +define install_man +$1: doc/$1.scd + scdoc < $$< > $$@ + +$1_section = $$(subst .,,$$(suffix $1)) +$1_install_dir = $$(DESTDIR)$$(MANDIR)/man$$($1_section) +dirs += $$($1_install_dir) +installed += $$($1_install_dir)/$1 + +$$($1_install_dir)/$1: $1 | $$($1_install_dir) + $$(INSTALL) -m644 $$< $$@ + +all: $1 +install: $$($1_install_dir)/$1 +endef + +# Generate build and install rules for one filter +# +# $1: filter source path or name +# +define install_filter +ifneq ($(wildcard filters/$1.c),) +$1: filters/$1.c + $$(CC) $$(CFLAGS) $$(LDFLAGS) -o $$@ $$< + +all: $1 +endif + +$1_install_dir = $$(DESTDIR)$$(LIBEXECDIR)/filters +dirs += $$($1_install_dir) +installed += $$($1_install_dir)/$$(notdir $1) + +$$($1_install_dir)/$$(notdir $1): $1 | $$($1_install_dir) + $$(CP) -af $$< $$@ + +install: $$($1_install_dir)/$$(notdir $1) +endef + +# Generate install rules for any file +# +# $1: source file +# $2: mode +# $3: target dir +# +define install_file +dirs += $3 +installed += $3/$$(notdir $1) + +$3/$$(notdir $1): $1 | $3 + $$(INSTALL) -m$2 $$< $$@ + +install: $3/$$(notdir $1) +endef + +# Call macros to generate build and install rules +$(foreach m,$(docs),\ + $(eval $(call install_man,$m))) +$(foreach f,$(filters) $(cfilters),\ + $(eval $(call install_filter,$f))) +$(foreach f,$(wildcard config/*.conf),\ + $(eval $(call install_file,$f,644,$(DESTDIR)$(SHAREDIR)))) +$(foreach s,$(wildcard stylesets/*),\ + $(eval $(call install_file,$s,644,$(DESTDIR)$(SHAREDIR)/stylesets))) +$(foreach t,$(wildcard templates/*),\ + $(eval $(call install_file,$t,644,$(DESTDIR)$(SHAREDIR)/templates))) +$(eval $(call install_file,contrib/aerc.desktop,644,$(DESTDIR)$(PREFIX)/share/applications)) +$(eval $(call install_file,aerc,755,$(DESTDIR)$(BINDIR))) +$(eval $(call install_file,contrib/carddav-query,755,$(DESTDIR)$(BINDIR))) + +$(sort $(dirs)): + mkdir -p $@ + +.DELETE_ON_ERROR: diff --git a/.pc/fix-temp-file-creation.patch/commands/msgview/open.go b/.pc/fix-temp-file-creation.patch/commands/msgview/open.go new file mode 100644 index 0000000..4293b7e --- /dev/null +++ b/.pc/fix-temp-file-creation.patch/commands/msgview/open.go @@ -0,0 +1,95 @@ +package msgview + +import ( + "errors" + "io" + "mime" + "os" + "path/filepath" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/lib" + "git.sr.ht/~rjarry/aerc/lib/log" +) + +type Open struct { + Delete bool `opt:"-d" desc:"Delete temp file after the opener exits."` + Cmd string `opt:"..." required:"false"` +} + +func init() { + commands.Register(Open{}) +} + +func (Open) Description() string { + return "Save the current message part to a temporary file, then open it." +} + +func (Open) Context() commands.CommandContext { + return commands.MESSAGE_VIEWER +} + +func (Open) Aliases() []string { + return []string{"open"} +} + +func (o Open) Execute(args []string) error { + mv := app.SelectedTabContent().(*app.MessageViewer) + if mv == nil { + return errors.New("open only supported selected message parts") + } + p := mv.SelectedMessagePart() + + mv.MessageView().FetchBodyPart(p.Index, func(reader io.Reader) { + mimeType := "" + + part, err := mv.MessageView().BodyStructure().PartAtIndex(p.Index) + if err != nil { + app.PushError(err.Error()) + return + } + mimeType = part.FullMIMEType() + + tmpDir, err := os.MkdirTemp(os.TempDir(), "aerc-*") + if err != nil { + app.PushError(err.Error()) + return + } + filename := part.FileName() + var tmpFile *os.File + if filename == "" { + extension := "" + if exts, _ := mime.ExtensionsByType(mimeType); len(exts) > 0 { + extension = exts[0] + } + tmpFile, err = os.CreateTemp(tmpDir, "aerc-*"+extension) + } else { + tmpFile, err = os.Create(filepath.Join(tmpDir, filename)) + } + if err != nil { + app.PushError(err.Error()) + return + } + + _, err = io.Copy(tmpFile, reader) + tmpFile.Close() + if err != nil { + app.PushError(err.Error()) + return + } + + go func() { + defer log.PanicHandler() + if o.Delete { + defer os.RemoveAll(tmpDir) + } + err = lib.XDGOpenMime(tmpFile.Name(), mimeType, o.Cmd) + if err != nil { + app.PushError("open: " + err.Error()) + } + }() + }) + + return nil +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..976bfdb --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,764 @@ +# Change Log + +All notable changes to aerc will be documented in this file. +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + +## [0.20.0](https://git.sr.ht/~rjarry/aerc/refs/0.20.0) - 2025-01-25 + +### Added + +- `copy-to` now supports multiple destination folders. +- All commands that involve composing messages (`:compose`, `:reply`, + `:recall`, `:unsubscribe` and `:forward`) now have a new `-s` flag to skip + opening the text editor and go directly to the review screen. Previously, + this flag was restricted to calendar invitations response commands + (`:accept`, `:accept-tentative` and `:decline`). + +### Fixed + +- `copy-to-replied` now properly works without having `copy-to` also set. +- `copy-to-replied` creates empty messages when `copy-to` is also set. +- The address-book completion popovers now again appear under the field being + completed. +- The new-message bell is now rung again for threaded directories as well. + +### Changed + +- The `default` styleset status line background has been reset to the default + color (light or dark, depending on your terminal color scheme) in order to + make error, warning or success messages more readable. +- Key bindings in the compose review screen are now displayed in the order in + which they are defined in the `[compose::review]` section of `binds.conf`. +- It is now possible to explicitly hide key bindings from the compose review + screen by using a special ` # -` annotation. + +### Closed Tickets + +- [#296: :compose: add flag to go directly to review screen](https://todo.sr.ht/~rjarry/aerc/296) + +## [0.19.0](https://git.sr.ht/~rjarry/aerc/refs/0.19.0) - 2025-01-14 + +### Added + +- New `:redraw` command to force a repaint of the screen. +- New `head` and `tail` templates functions for strings. +- New `{{.AccountFrom}}` template variable. +- Replying to all will include the Sender in Cc. +- Add `-b` flag to the `:view` command to open messages in a background tab. +- `AERC_ACCOUNT` and `AERC_FOLDER` are now available in the signature command + environment. +- Filters will receive the actual `COLUMNS` and `LINES` values. +- The `:forward` command now sets the forwarded flag. +- Forwarded messages can now be searched for and filtered in notmuch and + maildir. +- Forwarded messages can be styled differently in the message list. +- Forwarded messages can be identified with the `{{.IsForwarded}}` template. +- The `:flag` command now sets/unsets/toggle the forwarded tag. +- The notmuch backend now honors the forwarded flag, setting the `passed` tag. +- The maildir backend now honors the `forwarded`/`passed` flag. +- Auto-switch projects based on the message subject for the `:patch` command. +- New `:echo` command that prints its arguments with templates resolved. +- New `use-envelope-from` option in `accounts.conf`. +- Command completion now displays descriptions next to completion items. +- New `completion_description` style object in style sets used for rendering + completion item descriptions. +- `:import-mbox` can now import data from an URL. +- Dynamic message list style can now match on multiple email headers. +- The JMAP backend now supports full thread fetching and caching (limited + within a single mailbox). +- `:expand-folder` and `:collapse-folder` can now act on a non selected folder. +- Filters commands can now provide their own paging by prefixing them with a + `!` character. Doing so will disable the configured `[viewer].pager` and + connect them directly to the terminal. +- Reply to addresses in `From` and `Reply-To` headers with `:reply -f`. + +### Fixed + +- Builtin `calendar` filter shows empty attendee list. +- Terminal-based pinentry programs (e.g. `pinentry-curses`) now work properly. +- Failure to create IPC socket on Gentoo. +- Notmuch searches which explicitly contain tags from `exclude-tags` now return + messages. +- Invitations now honor the `:send -a` flag. +- Remove unwanted less than symbol from In-Reply-To header when Message-ID uses + folding. +- Aliases are now taken into account correctly when replying to own messages + such as from the Sent folder or via a mailing list. +- Some SMTP servers do not strip `Bcc` headers. aerc now removes them before + sending emails to avoid leaking private information. A new `strip-bcc = + false` option can be used in `accounts.conf` to revert to previous behaviour + (preserve `Bcc` headers in outgoing messages). +- There should no longer be any duplicates in recipient lists when replying. +- GPG signatures and encrypted parts now use CRLF line endings as required by + RFC 5322. + +### Changed + +- Template function `quote` only prefixes with a space if at quote depth `1`. +- Templates passed to the `:reply` command using the `-T` flag can now make use + of `{{.OriginalText}}`. +- The location of the command history file has changed to + `${XDG_STATE_HOME:-$HOME/.local/state}/aerc/history`. +- Tab completions for text fields are run asynchronously. In-flight requests + are cancelled when new input arrives. +- Path completion now uses the normal filtering mechanism, respecting case + sensitivity and the fuzzy completion option. +- The `html` filter is now enabled by default, making `w3m` a weak runtime + dependency. If it is not installed, viewing HTML emails will fail with an + explicit error. +- The default `text/html` filter will now run `w3m` in interactive mode. +- The builtin `html` and `html-unsafe` filters can now take additional + arguments that will be passed to `w3m`. This can be used to enable inline + images when viewing `text/html` parts (e.g.: `text/html = ! html-unsafe + -sixel`). +- The templates `exec` commands is now executed with the `filters` exec `$PATH` + similar to filter commands. +- The default `quoted_reply` template now converts `text/html` parts to plain + text before quoting them. + +### Deprecated + +- Support for go 1.20 and older. + +### Closed Tickets + +- [#150: Expand .Account with .Address and .Name](https://todo.sr.ht/~rjarry/aerc/150) +- [#202: pinentry-tty breaks aerc](https://todo.sr.ht/~rjarry/aerc/202) +- [#215: regression since bumping go-maildir](https://todo.sr.ht/~rjarry/aerc/215) +- [#220: add trim to templates](https://todo.sr.ht/~rjarry/aerc/220) +- [#226: Automatic patch switch based on email prefix](https://todo.sr.ht/~rjarry/aerc/226) +- [#232: $(tput cols) in filters report 80 all the time](https://todo.sr.ht/~rjarry/aerc/232) +- [#238: Implement decryption on action {cp,mv,pipe}](https://todo.sr.ht/~rjarry/aerc/238) +- [#250: allow disabling pager in filter](https://todo.sr.ht/~rjarry/aerc/250) +- [#259: :reply -a should reply to Sender as well](https://todo.sr.ht/~rjarry/aerc/259) +- [#266: Add opening individual emails in the background](https://todo.sr.ht/~rjarry/aerc/266) +- [#271: Add documentation to options in the autocomplete menu](https://todo.sr.ht/~rjarry/aerc/271) +- [#277: add :echo command](https://todo.sr.ht/~rjarry/aerc/277) +- [#281: Unable to open local `mbox` files](https://todo.sr.ht/~rjarry/aerc/281) +- [#283: BCC headers are exposed to recipients with gmail](https://todo.sr.ht/~rjarry/aerc/283) +- [#287: Crash when running :pipe -m less](https://todo.sr.ht/~rjarry/aerc/287) +- [#288: "could not MessageInfo ... NextPart: EOF" on a specific email](https://todo.sr.ht/~rjarry/aerc/288) +- [#294: Sender is not decoded in message view](https://todo.sr.ht/~rjarry/aerc/294) + +## [0.18.2](https://git.sr.ht/~rjarry/aerc/refs/0.18.2) - 2024-07-29 + +### Fixed + +- Builtin `calendar` filter error with non-GNU Awk. +- Detection of unicode width measurements on tmux 3.4. +- Dropping of events during large pastes. +- Home and End key decoding for the st terminal. + +## [0.18.1](https://git.sr.ht/~rjarry/aerc/refs/0.18.1) - 2024-07-15 + +### Fixed + +- Startup error if `log-file` directory does not exist. +- Aerc is now less pedantic about invalid headers for the maildir and notmuch + backends. +- Error when trying to configure `smtp-domain` with STARTTLS enabled. +- `smtp-domain` is now properly taken into account for TLS connections. + +## [0.18.0](https://git.sr.ht/~rjarry/aerc/refs/0.18.0) - 2024-07-02 + +### Added + +- Add `[ui].msglist-scroll-offset` option to set a scroll offset for the + message list. +- Add new `:align` command to align the selected message at the top, center, or + bottom of the message list. +- Inline image previews when no filter is defined for `image/*` and the + terminal supports it. +- `:bounce` command to reintroduce messages into the transport system. +- Message counts are available in statusline templates. +- Execute IPC commands verbatim by providing the command and its args as a + single argument in the shell. +- Virtually any key binding can now be configured in `binds.conf`, including + Shift+Alt+Control modifier combinations. +- Configure default message list `:split` or `:vsplit` on startup with + `message-list-split` in `aerc.conf`. +- Create notmuch named queries with the `:query` command. +- Specify a ":q" alias for quit. +- The `:detach` command now understands globs similar to `:attach`. +- Match filters on filename via `.filename,~ =`. +- Tell aerc how to handle file-based operations on multi-file notmuch messages + with the account config option `multi-file-strategy` and the `-m` flag to + `:archive`, `:copy`, `:delete`, and `:move`. +- Add `[ui].dialog-{position,width,height}` to set the position, width and + height of popover dialogs. +- New `pgp-self-encrypt` option in `accounts.conf`. +- Add `--no-ipc` flag to run `aerc mailto:...`, `aerc mbox:...`, and `aerc + :` within the current aerc instance and prevent listening for IPC + calls from other aerc instances. +- Add config options `disable-ipc-mailto` and `disable-ipc-mbox` to make + `mailto:...` and `mbox:...` commands always run in a new aerc instance. +- Set global options in `accounts.conf` by placing them at the top of the file. +- Silently close the terminal tab after piping a message to a command with + `:pipe -s `. +- New `tag-modified` hook for notmuch and JMAP accounts. +- New `flag-changed` hook. +- Notmuch search term completions to `:query`. +- Notmuch completions for `:cf`, `:filter` and `:search`. +- Add `imaps+insecure` to the available protocols, for connections that should + ignore issues with certificate verification. +- Add `[ui].select-last-message` option to position cursor at the bottom of the + view. +- Propagate terminal bell from the built-in terminal. +- Added `AERC_FOLDER_ROLE` to hooks that have `AERC_FOLDER`. +- Added `{{.AccountBackend}}` to templates. +- Added `AERC_ACCOUNT_BACKEND` to hooks with `AERC_ACCOUNT`. +- Per folder key bindings can now be defined for the message viewer. +- Allow using existing directory name with `:query -f`. +- Allow specifying the folder to delete with `:rmdir`. +- The address book is now used for `:cc`, `:bcc` and `:forward`. +- Allow fallback to threading by subject with `[ui].threading-by-subject`. + +### Fixed + +- Calendar responses now ignore case. +- Allow account- and folder-specific binds to coexist. +- Fixed crash when running `:send` with a `:preview` tab focused. +- Deadlock when running `aerc mailto:foo@bar.com` without another instance of + aerc already running. +- Prevent a freeze for large-scale deletions with IMAP. +- `Mime-Version` is no longer inserted in signed text parts headers. MTAs + normalizing header case will not corrupt signatures anymore. +- Restore previous behaviour of the new message bell which was broken in the + last two releases for at least some setups. + +### Changed + +- The default `[ui]` settings and the `default` styleset have changed + extensively. A no-color theme can be restored with the `monochrome` styleset. +- The default `colorize` theme has been changed to use the base terminal colors. +- The `[viewer]` section of stylesets now preserve default values as documented + in `aerc-stylesets(7)` unless explicitly overridden. +- Add Message-ID to the variables of `[hooks].mail-received`. +- The `TrayInfo` template variable now includes a visual mark mode indicator. +- The `disable-ipc` option in `aerc.conf` completely disables IPC. +- Improved readability of the builtin `calendar` filter. +- `:open` commands now preserve the original filename. +- Unparsable accounts are skipped, instead of aerc exiting with an error. + +### Deprecated + +- Built-in descriptions for the default keybinds shown on the review screen + will be deprecated in a future release. Descriptions can be added to those + keybinds with inline comments in binds.conf. + +## [0.17.0](https://git.sr.ht/~rjarry/aerc/refs/0.17.0) - 2024-02-01 + +### Added + +- New `flagged` criteria for `:sort`. +- New `:send-keys` command to control embedded terminals. +- Account aliases now support fnmatch-style wild cards. +- New `:suspend` command bound to `` by default. +- Disable parent context bindings by declaring them empty. +- Toggle folding with `:fold -t`. +- `mail-deleted` hook that triggers when a message is removed/moved from a + folder. +- `mail-added` hook that triggers when a message is added to a folder. +- Improved command completion. +- Customize key to trigger completion with `$complete` in `binds.conf`. +- Setting `complete-min-chars=manual` in `aerc.conf` now disables automatic + completion, leaving only manually triggered completion. +- `.ThreadUnread` is now available in templates. +- Allow binding commands to `Alt+` keys. +- `AERC_ACCOUNT` and `AERC_ADDRESS_BOOK_CMD` are now defined in the editor's + environment when composing a message. +- Reply with a different account than the current one with `:reply -A + `. +- New `[ui].tab-title-viewer` setting to configure the message viewer tab title. +- The `{{.Subject}}` template is evaluated to the new option + `[ui].empty-subject` if the subject is empty. +- Change to a folder of another account with `:cf -a `. +- Patch management with `:patch`. +- Add file path to messages in templates as `{{.Filename}}`. +- New `:menu` command to invoke other ex-commands based on a shell command + output. +- CLI flags to override paths to config files. +- Automatically attach signing key with `pgp-attach-key` in `accounts.conf`. +- Copy messages across accounts with `:cp -a `. +- Move messages across accounts with `:mv -a `. +- Support the `draft` flag. +- Thread arrow prefixes are now fully configurable. + +### Fixed + +- `colorize` support for wild cards `?` and `*`. +- Selection of headers in composer after `:compose -e` followed by `:edit -E`. +- Don't lose child messages of non-queried parents in notmuch threads +- Notmuch folders defined by the query `*` handle search, filter, and unread + counts correctly. + +### Changed + +- `:open` commands are now executed with `sh -c`. +- `:pipe` commands are now executed with `sh -c`. +- Message viewer tab titles will now show `(no subject)` if there is no subject + in the viewed email. +- Signature placement is now controlled via the `{{.Signature}}` template + variable and not hard coded. + +## [0.16.0](https://git.sr.ht/~rjarry/aerc/refs/0.16.0) - 2023-09-27 + +### Added + +- JMAP support. +- The new account wizard now supports all source and outgoing backends. +- Edit email headers directly in the text editor with `[compose].edit-headers` + in `aerc.conf` or with the `-e` flag for all compose related commands (e.g. + `:compose`, `:forward`, `:recall`, etc.). +- Use `:save -A` to save all the named parts, not just attachments. +- The `` key can now be bound. +- `colorize` can style diff chunk function names with `diff_chunk_func`. +- Warn before sending emails with an empty subject with `empty-subject-warning` + in `aerc.conf`. +- IMAP now uses the delimiter advertised by the server. +- `carddav-query` utility to use for `address-book-cmd`. +- Folder name mapping with `folder-map` in `accounts.conf`. +- Use `:open -d` to automatically delete temporary files. +- Remove headers from the compose window with `:header -d `. +- `:attach -r ` to pipe the attachments from a command. +- New `msglist_gutter` and `msglist_pill` styles for message list scrollbar. +- New `%f` placeholder to `file-picker-cmd` which expands to a location of a + temporary file from which selected files will be read instead of the standard + output. +- Save drafts in custom folders with `:postpone -t `. +- View "thread-context" in notmuch backends with `:toggle-thread-context`. + +### Fixed + +- `:archive` now works on servers using a different delimiter +- `:save -a` now works with multiple attachments with the same filename +- `:open` uses the attachment extension for temporary files, if possible +- memory leak when using notmuch with threading +- `:pipe ` now executes `sh -c ""` as indicated in the man page. + +### Changed + +- Names formatted like "Last Name, First Name" are better supported in templates +- Composing an email is now aborted if the text editor exits with an error + (e.g. with `vim`, abort an email with `:cq`). +- Aerc builtin filters path (usually `/usr/libexec/aerc/filters`) is now + **prepended** to the default system `PATH` to avoid conflicts with installed + distro binaries which have the same name as aerc builtin filters (e.g. + `/usr/bin/colorize`). +- `:export-mbox` only exports marked messages, if any. Otherwise it exports + everything, as usual. +- The local hostname is no longer exposed in outgoing `Message-Id` headers by + default. Legacy behaviour can be restored by setting `send-with-hostname + = true` in `accounts.conf`. +- The notmuch bindings were replaced with internal bindings +- Aerc now has a default style for most UI elements. The `default` styleset is + now empty. Existing stylesets will only override the default attributes if + they are set explicitly. To reset the default style and preserve existing + stylesets appearance, these two lines must be inserted **at the beginning**: + + ``` + *.default=true + *.normal=true + ``` +- Openers commands are not executed in with `sh -c`. + +### Deprecated + +- Aerc can no longer be compiled and installed with BSD make. GNU make must be + used instead. + +## [0.15.2](https://git.sr.ht/~rjarry/aerc/refs/0.15.2) - 2023-05-11 + +### Fixed + +- Extra messages disappearing when deleting on maildir. +- `colorize` and `wrap` filters option parsing on ARM. + +## [0.15.1](https://git.sr.ht/~rjarry/aerc/refs/0.15.1) - 2023-04-28 + +### Fixed + +- Embedded terminal partial refreshes. +- Maildir message updates after `mbsync`. + +## [0.15.0](https://git.sr.ht/~rjarry/aerc/refs/0.15.0) - 2023-04-26 + +### Added + +- New column-based message list format with `index-columns`. +- Add a `msglist_answered` style for answered messages. +- Compose `Format=Flowed` messages with `format-flowed=true` in `aerc.conf`. +- Add a `trimSignature` function to the templating engine. +- Change local domain name for SMTP with `smtp-domain=example.com` in + `aerc.conf` +- New column-based status line format with `status-columns`. +- Inline user-defined styles can be inserted in UI templates via the + `{{.Style "name" string}}` function. +- Add the ability to run arbitrary commands over the socket. This can be + disabled using the `disable-ipc` setting. +- Allow configuring URL handlers via `x-scheme-handler/` `[openers]` in + `aerc.conf`. +- Allow basic shell globbing in `[openers]` MIME types. +- Dynamic `msglist_*` styling based on email header values in stylesets. +- Add `mail-received`, `aerc-startup`, and `aerc-shutdown` hooks. +- Search/filter by flags with the `-H` flag. + +### Changed + +- Filters are now installed in `$PREFIX/libexec/aerc/filters`. The default exec + `PATH` has been modified to include all variations of the `libexec` subdirs. +- The built-in `colorize` filter theme is now configured in styleset files into + the `[viewer]` section. +- The standard Usenet signature delimiter `"-- "` is now prepended to + `signature-file` and `signature-cmd` if not already present. +- All `aerc(1)` commands now interpret `aerc-templates(7)` markup. +- running commands (like mailto: or mbox:) no longer prints a success message +- The built-in `colorize` filter now emits OSC 8 to mark URLs and emails. Set + `[general].enable-osc8 = true` in `aerc.conf` to enable it. +- Notmuch support is now automatically enabled when `notmuch.h` is detected on + the system. + +### Deprecated + +- `[ui].index-format` setting has been replaced by `index-columns`. +- `[statusline].render-format` has been replaced by `status-columns`. +- Removed support for go < 1.18. +- Removed support for `[ui:subject...]` contextual sections in `aerc.conf`. +- `[triggers]` setting has been replaced by `[hooks]`. +- `smtp-starttls` setting in `accounts.conf` has been removed. All `smtp://` + transports now assume `STARTTLS` and will fail if the server does not support + it. To disable `STARTTLS`, use `smtp+insecure://`. + +## [0.14.0](https://git.sr.ht/~rjarry/aerc/refs/0.14.0) - 2023-01-04 + +### Added + +- View common email envelope headers with `:envelope`. +- Notmuch accounts now support maildir operations: `:copy`, `:move`, `:mkdir`, + `:rmdir`, `:archive` and the `copy-to` option. +- Display messages from bottom to top with `[ui].reverse-msglist-order=true` in + `aerc.conf`. +- Display threads from bottom to top with `[ui].reverse-thread-order=true` in + `aerc.conf`. +- Style search results in the message list with `msglist_result.*`. +- Preview messages with their attachments before sending with `:preview`. +- Filter commands now have `AERC_FORMAT`, `AERC_SUBJECT` and `AERC_FROM` + defined in their environment. +- Override the subject prefix for replies pattern with `subject-re-pattern` in + `accounts.conf`. +- Search/filter by absolute and relative date ranges with the `-d` flag. +- LIST-STATUS and ORDEREDSUBJECT threading extensions support for imap. +- Built-in `wrap` filter that does not mess up nested quotes and lists. +- Write `multipart/alternative` messages with `:multipart` and commands defined + in the new `[multipart-converters]` section of `aerc.conf`. +- Close the message viewer before opening the composer with `:reply -c`. +- Attachment indicator in message list flags (by default `a`, but can be + changed via `[ui].icon-attachment` in `aerc.conf`). +- Open file picker menu with `:attach -m`. The menu must be generated by an + external command configured via `[compose].file-picker-cmd` in `aerc.conf`. +- Sample stylesets are now installed in `$PREFIX/share/aerc/stylesets`. +- The built-in `colorize` filter now has different themes. + +### Changed + +- `pgp-provider` now defaults to `auto`. It will use the system `gpg` unless + the internal keyring exists and contains at least one key. +- Calling `:split` or `:vsplit` without specifying a size, now attempts to use + the terminal size to determine a useful split-size. + +### Fixed + +- `:pipe -m git am -3` on patch series when `Message-Id` headers have not been + generated by `git send-email`. +- Overflowing text in header editors while composing can now be scrolled + horizontally. + +### Deprecated + +- Removed broken `:set` command. + +## [0.13.0](https://git.sr.ht/~rjarry/aerc/refs/0.13.0) - 2022-10-20 + +### Added + +- Support for bindings with the Alt modifier. +- Zoxide support with `:z`. +- Hide local timezone with `send-as-utc = true` in `accounts.conf`. +- Persistent command history in `~/.cache/aerc/history`. +- Cursor shape support in embedded terminals. +- Bracketed paste support. +- Display current directory in `status-line.render-format` with `%p`. +- Change accounts while composing a message with `:switch-account`. +- Override `:open` handler on a per-MIME-type basis in `aerc.conf`. +- Specify opener as the first `:open` param instead of always using default + handler (i.e. `:open gimp` to open attachment in GIMP). +- Restored XOAUTH2 support for IMAP and SMTP. +- Support for attaching files with `mailto:`-links +- Filter commands now have the `AERC_MIME_TYPE` and `AERC_FILENAME` variables + defined in their environment. +- Warn before sending emails that may need an attachment with + `no-attachment-warning` in `aerc.conf`. +- 3 panel view via `:split` and `:vsplit` +- Configure dynamic date format for the message viewer with + `message-view-this-*-time-format`. +- View message without marking it as seen with `:view -p`. + +### Changed + +- `:open-link` now supports link types other than HTTP(S) +- Running the same command multiple times only adds one entry to the command + history. +- Embedded terminal backend (libvterm was replaced by a pure go implementation). +- Filter commands are now executed with + `:~/.config/aerc/filters:~/.local/share/aerc/filters:$PREFIX/share/aerc/filters:/usr/share/aerc/filters` + appended to the exec `PATH`. This allows referencing aerc's built-in filter + scripts from their name only. + +### Fixed + +- `:open-link` will now detect links containing an exclamation mark +- `outgoing-cred-cmd` will no longer be executed every time an email needs to + be sent. The output will be stored until aerc is shut down. This behaviour + can be disabled by setting `outgoing-cred-cmd-cache=false` in + `accounts.conf`. +- Mouse support for embedded editors when `mouse-enabled=true`. +- Numerous race conditions. + +## [0.12.0](https://git.sr.ht/~rjarry/aerc/refs/0.12.0) - 2022-09-01 + +### Added + +- Read-only mbox backend support. +- Import/Export mbox files with `:import-mbox` and `:export-mbox`. +- `address-book-cmd` can now also be specified in `accounts.conf`. +- Run `check-mail-cmd` with `:check-mail`. +- Display active key binds with `:help keys` (bound to `?` by default). +- Multiple visual selections with `:mark -V`. +- Mark all messages of the same thread with `:mark -T`. +- Set default collapse depth of directory tree with `dirlist-collapse`. + +### Changed + +- Aerc will no longer exit while a send is in progress. +- When scrolling through large folders, client side threading is now debounced + to avoid lagging. This can be configured with `client-threads-delay`. +- The provided awk filters are now POSIX compliant and should work on MacOS and + BSD. +- `outgoing-cred-cmd` execution is now deferred until a message needs to be sent. +- `next-message-on-delete` now also applies to `:archive`. +- `:attach` now supports path globbing (`:attach *.log`) + +### Fixed + +- Transient crashes when closing tabs. +- Binding a command to `` and ``. +- Reselection after delete and scroll when client side threading is enabled. +- Background mail count polling when the default folder is empty on startup. +- Wide character handling in the message list. +- Issues with message reselection during scrolling and after `:delete` with + threading enabled. + +### Deprecated + +- Removed support for go < 1.16. + +## [0.11.0](https://git.sr.ht/~rjarry/aerc/refs/0.11.0) - 2022-07-11 + +### Added + +- Deal with calendar invites with `:accept`, `:accept-tentative` and `:decline`. +- IMAP cache support. +- Maildir++ support. +- Background mail count polling for all folders. +- Authentication-Results display (DKIM, SPF & DMARC). +- Folder-specific key bindings. +- Customizable PGP icons. +- Open URLs from messages with `:open-link`. +- Forward all individual attachments with `:forward -A`. + +### Changed + +- Messages are now deselected after performing a command. Use `:remark` to + reselect the previously selected messages and chain other commands. +- Pressing `` in the default postpone folder now runs `:recall` instead + of `:view`. +- PGP signed/encrypted indicators have been reworked. +- The `threading-enabled` option now affects if message threading should be + enabled at startup. This option no longer conflicts with `:toggle-threads`. + +### Fixed + +- `:pipe`, `:save` and `:open` for signed and/or encrypted PGP messages. +- Messages that have failed `gpg` encryption/signing are no longer sent. +- Recalling attachments from drafts. + +## [0.10.0](https://git.sr.ht/~rjarry/aerc/refs/0.10.0) - 2022-05-07 + +### Added + +- Format specifier for compact folder names in dirlist. +- Customizable, per-folder status line. +- Allow binding commands to `<` and `>` keys. +- Optional filter to parse ICS files (uses `python3` vobject library). +- Save all attachments with `:save -a`. +- Native `gpg` support. +- PGP `auto-sign` and `opportunistic-encrypt` options. +- Attach your PGP public key to a message with `:attach-key`. + +### Fixed + +- Stack overflow with faulty `References` headers when `:toggle-threads` is + enabled. + +## [0.9.0](https://git.sr.ht/~rjarry/aerc/refs/0.9.0) - 2022-03-21 + +### Added + +- Allow `:pipe` on multiple selected messages. +- Client side on-the-fly message threading with `:toggle-threads` (conflicts + with existing `threading-enabled` option). +- Per-account, better status line. +- Consecutive, incremental `:search` and `:filter` support. +- Foldable tree for directory list. +- `Bcc` and `Body` in `mailto:` handler. +- Fuzzy tab completion for commands and folders. +- Key pass though mode for the message viewer to allow searching with `less`. + +### Changed + +- Use terminfo for setting terminal title. + +## [0.8.2](https://git.sr.ht/~rjarry/aerc/refs/0.8.2) - 2022-02-19 + +### Added + +- New `colorize` filter with diff, multi-level quotes and URL coloring. +- XDG desktop entry to use as default `mailto:` handler. +- IMAP automatic reconnect. +- Recover drafts after crash with `:recover`. +- Show possible actions with user configured bindings when reviewing a message. +- Allow setting any header in email templates. +- Improved `:change-folder` responsiveness. +- New `:compose` option to never include your own address when replying. + +### Changed + +- Templates and style sets are now searched from multiple directories. Not from + a single hard-coded folder set at build time. In addition of the configured + `PREFIX/share/aerc` folders at build time, aerc now also looks into + `~/.config/aerc`, `~/.local/share/aerc`, `/usr/local/share/aerc` and + `/usr/share/aerc` +- A warning is displayed when trying to configure account specific bindings + for a non-existent account. + +### Fixed + +- `Ctrl-h` binding not working. +- Open files leaks for maildir and notmuch. + +## 0.8.1 - 2022-02-20 [YANKED] + +## 0.8.0 - 2022-02-19 [YANKED] + +## [0.7.1](https://git.sr.ht/~rjarry/aerc/refs/0.7.1) - 2022-01-15 + +### Added + +- IMAP low level TCP settings. +- Experimental IMAP server-side and notmuch threading. +- `:recall` now works from any folder. +- PGP/MIME signing and encryption. +- Account specific bindings. + +### Fixed + +- Address book completion for multiple addresses. +- Maildir external mailbox changes monitoring. + +## 0.7.0 - 2022-01-14 [YANKED] + +## [0.6.0](https://git.sr.ht/~rjarry/aerc/refs/0.6.0) - 2021-11-09 + +*The project was forked to .* + +### Added + +- Allow more modifiers for key bindings. +- Dynamic dates in message list. +- Match any header in filters specifiers. + +### Fixed + +- Don't read entire messages into memory. + +## [0.5.0](https://git.sr.ht/~sircmpwn/aerc/refs/0.5.0) - 2020-11-10 + +### Added + +- Remove folder with `:rmdir`. +- Configurable style sets. +- UI context aware options and styling. +- oauthbearer support for SMTP. +- IMAP sort support. + +## [0.4.0](https://git.sr.ht/~sircmpwn/aerc/refs/0.4.0) - 2020-05-20 + +### Added + +- Address book completion. +- Initial PGP support using an internal key store. +- Messages can now be selected with `:mark`. +- Drafts handing with `:postpone` and `:recall`. +- Tab management with `:move-tab` and `:pin-tab`. +- Add arbitrary headers in the compose window with `:header`. +- Interactive prompt with `:choose`. +- Notmuch labels improvements. +- Support setting some headers in message templates. + +### Changed + +- `aerc.conf` ini parser only uses `=` as delimiter. `:` is now ignored. + +## [0.3.0](https://git.sr.ht/~sircmpwn/aerc/refs/0.3.0) - 2019-11-21 + +### Added + +- A new notmuch backend is available. See `aerc-notmuch(5)` for details. +- Message templates now let you change the default reply and forwarded message + templates, as well as add new templates of your own. See `aerc-templates(7)` + for details. +- Mouse input is now optionally available and has been rigged up throughout the + UI, set `[ui]mouse-enabled=true` in `aerc.conf` to enable. +- `:cc` and `:bcc` commands are available in the message composer. +- Users may now configure arbitrary message headers for editing in the message + composer. + +## [0.2.0](https://git.sr.ht/~sircmpwn/aerc/refs/0.2.0) - 2019-07-29 + +### Added + +- Maildir & sendmail transport support +- Search and filtering are supported (via `/` and `\` by default) +- `aerc mailto:...` now opens the composer in running aerc instance +- Initial tab completion support has been added +- Improved headers and addressing in the composer and message view +- Message attachments may now be added in the composer +- Commands can now be run in the background with `:exec` or `:pipe -b` +- A new triggers system allows running aerc commands when new emails arrive, + which may (for example) be used to send desktop notifications or move new + emails to a folder + +### Changed + +- The filters have been rewritten in awk, dropping the Python dependencies. + `w3m` and `dante` are both still required for HTML email, but the HTML filter + has been commented out in the default config file. +- The default keybindings and configuration options have changed considerably, + and users are encouraged to pull the latest versions out of `/usr/share` and + re-apply their modifications to them, or to at least review the diff with + their current configurations. aerc may not behave properly without taking + this into account. + +## [0.1.0](https://git.sr.ht/~sircmpwn/aerc/refs/0.1.0) - 2019-06-03 + +Initial release. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..fa5984e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,359 @@ +# Contribution Guidelines + +This document contains guidelines for contributing code to aerc. It has to be +followed in order for your patch to be approved and applied. + +## Contribution Channels + +Anyone can contribute to aerc. First you need to clone the repository and build +the project: + + $ git clone https://git.sr.ht/~rjarry/aerc + $ cd aerc + $ gmake + +Patch the code. Write some tests. Ensure that your code is properly formatted +with `gofumpt`. Ensure that everything builds and works as expected. Ensure +that you did not break anything. + +- If applicable, update unit tests. +- If adding a new feature, please consider adding new tests. +- Do not forget to update the docs. +- Run the linter using `gmake lint`. + +Once you are happy with your work, you can create a commit (or several +commits). Follow these general rules: + +- Limit the first line (title) of the commit message to 60 characters. +- Use a short prefix for the commit title for readability with `git log + --oneline`. Do not use the `fix:` nor `feature:` prefixes. See recent commits + for inspiration. +- Only use lower case letters for the commit title except when quoting symbols + or known acronyms. +- Use the body of the commit message to actually explain what your patch does + and why it is useful. Even if your patch is a one line fix, the description + is not limited in length and may span over multiple paragraphs. Use proper + English syntax, grammar and punctuation. +- Address only one issue/topic per commit. +- Describe your changes in imperative mood, e.g. *"make xyzzy do frotz"* + instead of *"[This patch] makes xyzzy do frotz"* or *"[I] changed xyzzy to do + frotz"*, as if you are giving orders to the codebase to change its behaviour. +- If you are fixing a ticket, use appropriate + [commit trailers](https://man.sr.ht/git.sr.ht/#referencing-tickets-in-git-commit-messages). +- If you are fixing a regression introduced by another commit, add a `Fixes:` + trailer with the commit id and its title. +- When in doubt, follow the format and layout of the recent existing commits. +- If your commit brings visible changes for end-users, add one of the following + trailers with a short and concise description of the change. The change + should be described in full sentences, starting with a capital letter and + ending in a period. + + * `Changelog-added:` for new features. + * `Changelog-fixed:` for bug fixes. + * `Changelog-changed:` for behaviour or config format changes. + * `Changelog-deprecated:` for deprecation or removal of functionality. + + If a complete trailer is longer than 72 characters, it can be continued by + indenting extra lines with a single space. The trailer text must be valid + markdown. You can take inspiration from existing entries in + [CHANGELOG.md](https://git.sr.ht/~rjarry/aerc/tree/master/item/CHANGELOG.md). +- The following trailers are accepted in commits. If you are using multiple + trailers in a commit, it's preferred to also order them according to this + list. Note, that the `commit-msg` hook (see below for installing) will + automatically sort them for you when committing. + + * `Closes: ` closes the ticket with the neutral `CLOSED` resolution. + * `Fixes: ` closes the ticket with the `FIXED` resolution. + * `Fixes: ("")` reference the commit that introduced a regression. + * `Implements: <URL>` closes the ticket with the `IMPLEMENTED` resolution. + * `References: <URL>` adds a comment to the ticket. + * `Link:` + * `Changelog-added:` + * `Changelog-fixed:` + * `Changelog-changed:` + * `Changelog-deprecated:` + * `Cc:` + * `Suggested-by:` + * `Requested-by:` + * `Reported-by:` + * `Co-authored-by:` + * `Signed-off-by:` compulsory! + * `Tested-by:` used in review _after_ submission to the mailing list. If + minimal changes occur between respins, feel free to include that into your + respin to keep track of previous reviews. + * `Reviewed-by:` used in review _after_ submission. If minimal changes occur + between respins, feel free to include that into your respin to keep track + of previous reviews. + * `Acked-by:` used in review _after_ submission. + +There is a great reference for commit messages in the +[Linux kernel documentation](https://www.kernel.org/doc/html/latest/process/submitting-patches.html#describe-your-changes). + +IMPORTANT: you must sign-off your work using `git commit --signoff`. Follow the +[Linux kernel developer's certificate of origin][linux-signoff] for more +details. All contributions are made under the MIT license. If you do not want +to disclose your real name, you may sign-off using a pseudonym. Here is an +example: + + Signed-off-by: Robin Jarry <robin@jarry.cc> + +Before sending the patch, you should configure your local clone with sane +defaults: + + $ gmake gitconfig + git config format.subjectPrefix "PATCH aerc" + git config sendemail.to "~rjarry/aerc-devel@lists.sr.ht" + git config format.notes true + git config notes.rewriteRef refs/notes/commits + git config notes.rewriteMode concatenate + ln -s ../../contrib/commit-msg .git/hooks/commit-msg + + ln -s ../../contrib/sendemail-validate .git/hooks/sendemail-validate + + git config sendemail.validate true + +And send the patch to the mailing list ([step-by-step +instructions][git-send-email-tutorial]): + + $ git send-email --annotate -1 + +If you are sending a patch against the `wiki` branch, make sure to change the +subject prefix to avoid triggering the automated builds that will inevitably +fail: + + $ git send-email --annotate -1 --subject-prefix="PATCH aerc/wiki" + +Before your patch can be applied, it needs to be reviewed and approved by +others. They will indicate their approval by replying to your patch with +a [Tested-by, Reviewed-by or Acked-by][linux-review] (see also: [the git +wiki][git-trailers]) trailer. For example: + + Acked-by: Robin Jarry <robin@jarry.cc> + +There is no "chain of command" in aerc. Anyone that feels comfortable enough to +"ack" or "review" a patch should express their opinion freely with an official +Acked-by or Reviewed-by trailer. If you only tested that a patch works as +expected but did not conduct a proper code review, you can indicate it with +a Tested-by trailer. + +You can follow the review process via email and on the [web ui][web-ui]. + +Wait for feedback. Address comments and amend changes to your original commit. +Then you should send a v2 (and maybe a v3, v4, etc.): + + $ git send-email --annotate -v2 -1 + +Be polite, patient and address *all* of the reviewers' remarks. If you disagree +with something, feel free to discuss it. + +To help reviewers track what changed between respins of your patch, it is nice +to include a mini change log **after** the `---` line that separates your +commit message from the diff. You can either do that manually when reviewing +(`git send-email --annotate`) before sending your email, or you can use [git +notes][git-notes] to make this part of your git workflow: + + $ git notes edit $ref + +[git-notes]: https://git-scm.com/docs/git-notes + +When `format.notes = true` is set in your git configuration, notes attached to +commits will automatically be included in the correct location by `git +format-patch` and `git send-email`. + +If you have set `notes.rewriteMode = concatenate`, squashing commits together +with `git rebase -i` will also merge their respective notes by concatenating +them. + +Once your patch has been reviewed and approved (and if the maintainer is OK +with it), it will be applied and pushed. + +IMPORTANT: Do NOT use `--in-reply-to` when sending followup versions of a patch +set. It causes multiple versions of the same patch to be merged under v1 in the +[web ui][web-ui] + +[web-ui]: https://lists.sr.ht/~rjarry/aerc-devel/patches + +## Code Style + +Please refer only to the quoted sections when guidelines are sourced from +outside documents as some rules of the source material may conflict with other +rules set out in this document. + +When updating an existing file, respect the existing coding style unless there +is a good reason not to do so. + +### Indentation + +Indentation rules follow the Linux kernel coding style: + +> Tabs are 8 characters, and thus indentations are also 8 characters. […] +> +> Rationale: The whole idea behind indentation is to clearly define where +> a block of control starts and ends. Especially when you’ve been looking at +> your screen for 20 straight hours, you’ll find it a lot easier to see how the +> indentation works if you have large indentations. +> — [Linux kernel coding style][linux-coding-style] + +### Breaking long lines and strings + +Wrapping rules follow the Linux kernel coding style: + +> Coding style is all about readability and maintainability using commonly +> available tools. +> +> The preferred limit on the length of a single line is 80 columns. +> +> Statements longer than 80 columns should be broken into sensible chunks, +> unless exceeding 80 columns significantly increases readability and does not +> hide information. +> […] +> These same rules are applied to function headers with a long argument list. +> +> However, never break user-visible strings such as printk messages because +> that breaks the ability to grep for them. +> — [Linux kernel coding style][linux-coding-style] + +Whether or not wrapping lines is acceptable can be discussed on IRC or the +mailing list, when in doubt. + +### Functions + +Function rules follow the Linux kernel coding style: + +> Functions should be short and sweet, and do just one thing. They should fit +> on one or two screenfuls of text (the ISO/ANSI screen size is 80x24, as we +> all know), and do one thing and do that well. +> +> The maximum length of a function is inversely proportional to the complexity +> and indentation level of that function. So, if you have a conceptually simple +> function that is just one long (but simple) case-statement, where you have to +> do lots of small things for a lot of different cases, it’s OK to have +> a longer function. +> +> However, if you have a complex function, and you suspect that +> a less-than-gifted first-year high-school student might not even understand +> what the function is all about, you should adhere to the maximum limits all +> the more closely. Use helper functions with descriptive names (you can ask +> the compiler to in-line them if you think it’s performance-critical, and it +> will probably do a better job of it than you would have done). +> +> Another measure of the function is the number of local variables. They +> shouldn’t exceed 5-10, or you’re doing something wrong. Re-think the +> function, and split it into smaller pieces. A human brain can generally +> easily keep track of about 7 different things, anything more and it gets +> confused. You know you’re brilliant, but maybe you’d like to understand what +> you did 2 weeks from now. +> — [Linux kernel coding style][linux-coding-style] + +### Commenting + +Function rules follow the Linux kernel coding style: + +> Comments are good, but there is also a danger of over-commenting. NEVER try +> to explain HOW your code works in a comment: it’s much better to write the +> code so that the working is obvious, and it’s a waste of time to explain +> badly written code. +> +> Generally, you want your comments to tell WHAT your code does, not HOW. Also, +> try to avoid putting comments inside a function body: if the function is so +> complex that you need to separately comment parts of it, you should probably +> go back to [the previous section regarding functions] for a while. You can +> make small comments to note or warn about something particularly clever (or +> ugly), but try to avoid excess. Instead, put the comments at the head of the +> function, telling people what it does, and possibly WHY it does it. +> +> When commenting […] API functions, please use the [GoDoc] format. See the +> [official documentation][godoc-comments] for details. +> — [Linux kernel coding style][linux-coding-style] + +### Editor modelines + +> Some editors can interpret configuration information embedded in source +> files, indicated with special markers. For example, emacs interprets lines +> marked like this: +> +> -*- mode: c -*- +> +> Or like this: +> +> /* +> Local Variables: +> compile-command: "gcc -DMAGIC_DEBUG_FLAG foo.c" +> End: +> */ +> +> Vim interprets markers that look like this: +> +> /* vim:set sw=8 noet */ +> +> Do not include any of these in source files. People have their own personal +> editor configurations, and your source files should not override them. This +> includes markers for indentation and mode configuration. People may use +> their own custom mode, or may have some other magic method for making +> indentation work correctly. +> — [Linux kernel coding style][linux-coding-style] + +In the same way, files specific to only your workflow (for example the `.idea` +or `.vscode` directory) are not desired. If a script might be useful to other +contributors, it can be sent as a separate patch that adds it to the `contrib` +directory. Since it is not editor-specific, an +[`.editorconfig`](https://git.sr.ht/~rjarry/aerc/tree/master/item/.editorconfig) +is available in the repository. + +### Go-code + +The Go-code follows the rules of [gofumpt][gofumpt-repo] which is equivalent to +gofmt but adds a few additional rules. The code can be automatically formatted +by running `gmake fmt`. + +If gofumpt accepts your code it's most likely properly formatted. + +### Logging + +Aerc allows logging messages to a file. Either by redirecting the output to +a file (e.g. `aerc > log`), or by configuring `log-file` in `aerc.conf`. +Logging messages are associated with a severity level, from lowest to highest: +`trace`, `debug`, `info`, `warn`, `error`. + +Messages can be sent to the log file by using the following functions: + +- `log.Errorf()`: Use to report serious (but non-fatal) errors. +- `log.Warnf()`: Use to report issues that do not affect normal use. +- `log.Infof()`: Use to display important messages that may concern + non-developers. +- `log.Debugf()`: Use to display non-important messages, or debugging + details. +- `log.Tracef()`: Use to display only low level debugging traces. + +### Man pages + +All `doc/*.scd` files are written in the [scdoc][scdoc] format and compiled to +man pages. + +For consistent rendering, please respect the following guidelines: + +- use `*:command*` to reference commands +- use `*-x*` for flags +- use `_<arg>_` argument placeholders that must be replaced by a suitable value +- use `_foobar.conf_` for file paths +- use `_true_`, `_0_`, `_constant_` for literal constants that must be typed as is +- use `[*-x*]` or `[_<arg>_]` for optional flags/arguments +- use `*-x*|*-y*` for mutually exclusive flags/arguments +- use `*[section]*` to reference sections in configuration files +- use `*foo*` or `*[section].foo*` to reference settings +- if an option does **not** have a default value, simply omit it +- use `*FOO*` and `*$FOO*` for environment variables +- only use `_"quoted values"_` when white space matters +- put command alternatives/aliases on separate lines with `++` suffixes +- use `*<c-x>*` or `*<enter>*` to reference key strokes +- use `# UPPER CASE` for man page sections +- use `*aerc-config*(5)` to reference other man pages +- use `aerc` (instead of `*aerc*` or `_aerc_`) to reference the aerc project or + the aerc program + +[git-send-email-tutorial]: https://git-send-email.io/ +[git-trailers]: https://git.wiki.kernel.org/index.php/CommitMessageConventions +[godoc-comments]: https://go.dev/blog/godoc +[gofumpt-repo]: https://github.com/mvdan/gofumpt +[linux-coding-style]: https://www.kernel.org/doc/html/v5.19-rc8/process/coding-style.html +[linux-review]: https://www.kernel.org/doc/html/latest/process/submitting-patches.html#using-reported-by-tested-by-reviewed-by-suggested-by-and-fixes +[linux-signoff]: https://www.kernel.org/doc/html/latest/process/submitting-patches.html#sign-your-work-the-developer-s-certificate-of-origin +[scdoc]: https://git.sr.ht/~sircmpwn/scdoc diff --git a/GNUmakefile b/GNUmakefile new file mode 100644 index 0000000..72e17c4 --- /dev/null +++ b/GNUmakefile @@ -0,0 +1,214 @@ +# variables that can be changed by users +# +VERSION ?= $(shell git describe --long --abbrev=12 --tags --dirty 2>/dev/null || echo 0.20.0) +DATE ?= $(shell date +%Y-%m-%d) +PREFIX ?= /usr/local +BINDIR ?= $(PREFIX)/bin +SHAREDIR ?= $(PREFIX)/share/aerc +LIBEXECDIR ?= $(PREFIX)/libexec/aerc +MANDIR ?= $(PREFIX)/share/man +GO ?= go +INSTALL ?= install +CP ?= cp +GOFLAGS ?= $(shell contrib/goflags.sh) +BUILD_OPTS ?= -trimpath +GO_LDFLAGS := +GO_LDFLAGS += -X main.Version=$(VERSION) +GO_LDFLAGS += -X main.Date=$(DATE) +GO_LDFLAGS += -X git.sr.ht/~rjarry/aerc/config.shareDir=$(SHAREDIR) +GO_LDFLAGS += -X git.sr.ht/~rjarry/aerc/config.libexecDir=$(LIBEXECDIR) +GO_LDFLAGS += $(GO_EXTRA_LDFLAGS) +CC ?= cc +CFLAGS ?= -O2 -g + +# internal variables used for automatic rules generation with macros +gosrc = $(shell find * -type f -name '*.go') go.mod go.sum +man1 = $(subst .scd,,$(notdir $(wildcard doc/*.1.scd))) +man5 = $(subst .scd,,$(notdir $(wildcard doc/*.5.scd))) +man7 = $(subst .scd,,$(notdir $(wildcard doc/*.7.scd))) +docs = $(man1) $(man5) $(man7) +cfilters = $(subst .c,,$(notdir $(wildcard filters/*.c))) +filters = $(filter-out filters/vectors filters/test.sh filters/%.c,$(wildcard filters/*)) +gofumpt_tag = v0.7.0 + +# Dependencies are added dynamically to the "all" rule with macros +.PHONY: all +all: aerc + @: + +aerc: $(gosrc) + $(GO) build $(BUILD_OPTS) $(GOFLAGS) -ldflags "$(GO_LDFLAGS)" -o aerc + +.PHONY: dev +dev: + $(RM) aerc + $(MAKE) --no-print-directory aerc BUILD_OPTS="-trimpath -race" + GORACE="log_path=race.log strip_path_prefix=git.sr.ht/~rjarry/aerc/" ./aerc + +.PHONY: fmt +fmt: + $(GO) run mvdan.cc/gofumpt@$(gofumpt_tag) -w . + +.PHONY: lint +lint: + @contrib/check-whitespace `git ls-files ':!:filters/vectors'` && \ + echo white space ok. + @contrib/check-docs && echo docs ok. + @$(GO) run mvdan.cc/gofumpt@$(gofumpt_tag) -d . | grep ^ \ + && echo The above files need to be formatted, please run make fmt && exit 1 \ + || echo all files formatted. + codespell * + $(GO) run github.com/golangci/golangci-lint/cmd/golangci-lint@v1.62.2 run \ + $$(echo $(GOFLAGS) | sed s/-tags=/--build-tags=/) + $(GO) run $(GOFLAGS) contrib/linters.go ./... + +.PHONY: vulncheck +vulncheck: + $(GO) run golang.org/x/vuln/cmd/govulncheck@latest ./... + +.PHONY: tests +tests: $(cfilters) + $(GO) test $(GOFLAGS) ./... + filters/test.sh + +.PHONY: debug +debug: aerc.debug + @echo 'Run `./aerc.debug` and use this command in another terminal to attach a debugger:' + @echo ' dlv attach $$(pidof aerc.debug)' + +aerc.debug: $(gosrc) + $(GO) build $(subst -trimpath,,$(GOFLAGS)) -gcflags=all="-N -l" -ldflags="$(GO_LDFLAGS)" -o aerc.debug + +.PHONY: doc +doc: $(docs) + @: + +.PHONY: clean +clean: + $(RM) $(docs) aerc $(cfilters) + +# Dependencies are added dynamically to the "install" rule with macros +.PHONY: install +install: + @: + +.PHONY: checkinstall +checkinstall: + $(DESTDIR)$(BINDIR)/aerc -v + for m in $(man1); do test -e $(DESTDIR)$(MANDIR)/man1/$$m || exit; done + for m in $(man5); do test -e $(DESTDIR)$(MANDIR)/man5/$$m || exit; done + for m in $(man7); do test -e $(DESTDIR)$(MANDIR)/man7/$$m || exit; done + +.PHONY: uninstall +uninstall: + @echo $(installed) | tr ' ' '\n' | sort -ru | while read -r f; do \ + echo rm -f $$f && rm -f $$f || exit; \ + done + @echo $(dirs) | tr ' ' '\n' | sort -ru | while read -r d; do \ + if [ -d $$d ] && ! ls -Aq1 $$d | grep -q .; then \ + echo rmdir $$d && rmdir $$d || exit; \ + fi; \ + done + +.PHONY: gitconfig +gitconfig: + git config format.subjectPrefix "PATCH aerc" + git config sendemail.to "~rjarry/aerc-devel@lists.sr.ht" + git config format.notes true + git config notes.rewriteRef refs/notes/commits + git config notes.rewriteMode concatenate + @mkdir -p .git/hooks + @rm -f .git/hooks/commit-msg* + ln -s ../../contrib/commit-msg .git/hooks/commit-msg + @rm -f .git/hooks/sendemail-validate* + @if grep -q GIT_SENDEMAIL_FILE_COUNTER `git --exec-path`/git-send-email 2>/dev/null; then \ + set -xe; \ + ln -s ../../contrib/sendemail-validate .git/hooks/sendemail-validate && \ + git config sendemail.validate true; \ + fi + +.PHONY: check-patches +check-patches: + @contrib/check-patches origin/master.. + +.PHONY: validate +validate: CFLAGS = -Wall -Wextra -Wconversion -Werror -Wformat-security -Wstack-protector -Wpedantic -Wmissing-prototypes +validate: all tests lint check-patches + +# Generate build and install rules for one man page +# +# $1: man page name (e.g: aerc.1) +# +define install_man +$1: doc/$1.scd + scdoc < $$< > $$@ + +$1_section = $$(subst .,,$$(suffix $1)) +$1_install_dir = $$(DESTDIR)$$(MANDIR)/man$$($1_section) +dirs += $$($1_install_dir) +installed += $$($1_install_dir)/$1 + +$$($1_install_dir)/$1: $1 | $$($1_install_dir) + $$(INSTALL) -m644 $$< $$@ + +all: $1 +install: $$($1_install_dir)/$1 +endef + +# Generate build and install rules for one filter +# +# $1: filter source path or name +# +define install_filter +ifneq ($(wildcard filters/$1.c),) +$1: filters/$1.c + $$(CC) $$(CFLAGS) $$(CPPFLAGS) $$(LDFLAGS) -o $$@ $$< + +all: $1 +endif + +$1_install_dir = $$(DESTDIR)$$(LIBEXECDIR)/filters +dirs += $$($1_install_dir) +installed += $$($1_install_dir)/$$(notdir $1) + +$$($1_install_dir)/$$(notdir $1): $1 | $$($1_install_dir) + $$(CP) -af $$< $$@ + +install: $$($1_install_dir)/$$(notdir $1) +endef + +# Generate install rules for any file +# +# $1: source file +# $2: mode +# $3: target dir +# +define install_file +dirs += $3 +installed += $3/$$(notdir $1) + +$3/$$(notdir $1): $1 | $3 + $$(INSTALL) -m$2 $$< $$@ + +install: $3/$$(notdir $1) +endef + +# Call macros to generate build and install rules +$(foreach m,$(docs),\ + $(eval $(call install_man,$m))) +$(foreach f,$(filters) $(cfilters),\ + $(eval $(call install_filter,$f))) +$(foreach f,$(wildcard config/*.conf),\ + $(eval $(call install_file,$f,644,$(DESTDIR)$(SHAREDIR)))) +$(foreach s,$(wildcard stylesets/*),\ + $(eval $(call install_file,$s,644,$(DESTDIR)$(SHAREDIR)/stylesets))) +$(foreach t,$(wildcard templates/*),\ + $(eval $(call install_file,$t,644,$(DESTDIR)$(SHAREDIR)/templates))) +$(eval $(call install_file,contrib/aerc.desktop,644,$(DESTDIR)$(PREFIX)/share/applications)) +$(eval $(call install_file,aerc,755,$(DESTDIR)$(BINDIR))) +$(eval $(call install_file,contrib/carddav-query,755,$(DESTDIR)$(BINDIR))) + +$(sort $(dirs)): + mkdir -p $@ + +.DELETE_ON_ERROR: diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..15878ed --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +Copyright (c) 2018-2019 Drew DeVault +Copyright (c) 2021-2022 Robin Jarry + +The MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MAINTAINERS b/MAINTAINERS new file mode 100644 index 0000000..168b734 --- /dev/null +++ b/MAINTAINERS @@ -0,0 +1,16 @@ +Below is a list of maintainers of this project. They have push access to the +git repository, moderation access to the mailing list and the bug tracker. + +Git Push Access +=============== + +Robin Jarry <robin@jarry.cc> + +List Moderation, Patch Triage, Bug Tracker Triage +================================================= + +Bence Ferdinandy <bence@ferdinandy.com> +Inwit <inwit@sindominio.net> +Koni Marti <koni.marti@gmail.com> +Moritz Poldrack <moritz@poldrack.dev> +Tim Culverhouse <tim@timculverhouse.com> diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ebbfb42 --- /dev/null +++ b/Makefile @@ -0,0 +1,7 @@ +# This file is only left here for explicit error about GNU make requirement +# when building with other make flavours. +# +# Do not edit this file. Edit GNUmakefile instead. +.PHONY: all +all .DEFAULT: + @echo "Please build and install using GNU make (gmake)"; exit 1 diff --git a/README.md b/README.md new file mode 100644 index 0000000..2b8a920 --- /dev/null +++ b/README.md @@ -0,0 +1,150 @@ +# aerc + +[![builds.sr.ht status](https://builds.sr.ht/~rjarry/aerc.svg)](https://builds.sr.ht/~rjarry/aerc) +[![GitHub macOS CI status](https://github.com/rjarry/aerc/actions/workflows/macos.yml/badge.svg)](https://github.com/rjarry/aerc/actions/workflows/macos.yml) + +[aerc](https://sr.ht/~rjarry/aerc/) is an email client for your terminal. + +This is a fork of [the original aerc](https://git.sr.ht/~sircmpwn/aerc) +by Drew DeVault. + +A short demonstration can be found on [https://aerc-mail.org/](https://aerc-mail.org/) + +Join the IRC channel: [#aerc on irc.libera.chat](http://web.libera.chat/?channels=aerc) +for end-user support, and development. + +## Usage + +On its first run, aerc will copy the default config files to `~/.config/aerc` +on Linux or `~/Library/Preferences/aerc` on MacOS (or `$XDG_CONFIG_HOME/aerc` if set) +and show the account configuration wizard. + +If you redirect stdout to a file, logging output will be written to that file: + + $ aerc > log + +Note that the default HTML filter additionally needs `w3m` to be installed +along with optional `unshare` (from `util-linux`) or `socksify` (from +`dante-utils`). + +### Documentation + +Also available as man pages: + +- [aerc(1)](https://git.sr.ht/~rjarry/aerc/tree/master/item/doc/aerc.1.scd) +- [aerc-accounts(5)](https://git.sr.ht/~rjarry/aerc/tree/master/item/doc/aerc-accounts.5.scd) +- [aerc-binds(5)](https://git.sr.ht/~rjarry/aerc/tree/master/item/doc/aerc-binds.5.scd) +- [aerc-config(5)](https://git.sr.ht/~rjarry/aerc/tree/master/item/doc/aerc-config.5.scd) +- [aerc-imap(5)](https://git.sr.ht/~rjarry/aerc/tree/master/item/doc/aerc-imap.5.scd) +- [aerc-jmap(5)](https://git.sr.ht/~rjarry/aerc/tree/master/item/doc/aerc-jmap.5.scd) +- [aerc-maildir(5)](https://git.sr.ht/~rjarry/aerc/tree/master/item/doc/aerc-maildir.5.scd) +- [aerc-notmuch(5)](https://git.sr.ht/~rjarry/aerc/tree/master/item/doc/aerc-notmuch.5.scd) +- [aerc-patch(7)](https://git.sr.ht/~rjarry/aerc/tree/master/item/doc/aerc-patch.7.scd) +- [aerc-search(1)](https://git.sr.ht/~rjarry/aerc/tree/master/item/doc/aerc-search.1.scd) +- [aerc-sendmail(5)](https://git.sr.ht/~rjarry/aerc/tree/master/item/doc/aerc-sendmail.5.scd) +- [aerc-smtp(5)](https://git.sr.ht/~rjarry/aerc/tree/master/item/doc/aerc-smtp.5.scd) +- [aerc-stylesets(7)](https://git.sr.ht/~rjarry/aerc/tree/master/item/doc/aerc-stylesets.7.scd) +- [aerc-templates(7)](https://git.sr.ht/~rjarry/aerc/tree/master/item/doc/aerc-templates.7.scd) +- [aerc-tutorial(7)](https://git.sr.ht/~rjarry/aerc/tree/master/item/doc/aerc-tutorial.7.scd) +- [carddav-query(1)](https://git.sr.ht/~rjarry/aerc/tree/master/item/doc/carddav-query.1.scd) + +User contributions and integration with external tools: + +- [wiki](https://man.sr.ht/~rjarry/aerc/) + +## Installation + +### Binary Packages + +Recent versions of aerc are available on: + +- [Alpine](https://pkgs.alpinelinux.org/packages?name=aerc) +- [Arch](https://archlinux.org/packages/extra/x86_64/aerc/) +- [Debian](https://tracker.debian.org/pkg/aerc) +- [Fedora](https://packages.fedoraproject.org/pkgs/aerc/aerc/) +- [openSUSE](https://build.opensuse.org/package/show/openSUSE:Factory/aerc) +- [macOS through Homebrew](https://formulae.brew.sh/formula/aerc) +- [Slackware](https://slackbuilds.org/result/?search=aerc) + +And likely other platforms. + +### From Source + +Install the dependencies: + +- go (>=1.21) *(Go versions are supported until their end-of-life; support for + older versions may be dropped at any time due to incompatibilities or newer + required language features.)* +- [scdoc](https://git.sr.ht/~sircmpwn/scdoc) +- GNU make + +Then compile aerc: + + $ gmake + +aerc optionally supports notmuch. To enable it, you need to have a recent +version of [notmuch](https://notmuchmail.org/#index7h2), including the header +files (notmuch.h). The `notmuch` build tag should be automatically added. To +check if it is, run the following command: + + $ ./aerc -v + aerc 0.14.0-108-g31e1cd9af565 +notmuch (go1.19.6 amd64 linux) + ^^^^^^^^ + +If it is not, you can force it before building: + + $ gmake GOFLAGS=-tags=notmuch + +If you have notmuch headers available but do not want to build notmuch support +in aerc, force GOFLAGS to an empty value: + + $ gmake GOFLAGS= + +To install aerc locally: + + # gmake install + +By default, aerc will install config files to directories under `/usr/local/aerc`, +and will search for templates and stylesets in these locations in order: + +- `${XDG_CONFIG_HOME:-~/.config}/aerc` +- `${XDG_DATA_HOME:-~/.local/share}/aerc` +- `/usr/local/share/aerc` +- `/usr/share/aerc` + +At build time it is possible to add an extra location to this list and to use +that location as the default install location for config files by setting the +`PREFIX` option like so: + + # gmake PREFIX=/custom/location + # gmake install PREFIX=/custom/location + +This will install templates and other config files to `/custom/location/share/aerc`, +and man pages to `/custom/location/share/man`. This extra location will have lower +priority than the XDG locations but higher than the fixed paths. + +## Contributing + +Anyone can contribute to aerc. Please refer to [the contribution +guidelines](https://git.sr.ht/~rjarry/aerc/tree/master/item/CONTRIBUTING.md) + +## Resources + +Ask for support or follow general discussions on +[~rjarry/aerc-discuss@lists.sr.ht](https://lists.sr.ht/~rjarry/aerc-discuss). + +Send patches and development related questions to +[~rjarry/aerc-devel@lists.sr.ht](https://lists.sr.ht/~rjarry/aerc-devel). + +Instructions for preparing a patch are available at +[git-send-email.io](https://git-send-email.io) + +Subscribe to release announcements on +[~rjarry/aerc-announce@lists.sr.ht](https://lists.sr.ht/~rjarry/aerc-announce) + +Submit *confirmed* bug reports and *confirmed* feature requests on +[https://todo.sr.ht/~rjarry/aerc](https://todo.sr.ht/~rjarry/aerc). + +[License](https://git.sr.ht/~rjarry/aerc/tree/master/item/LICENSE). + +[Change log](https://git.sr.ht/~rjarry/aerc/tree/master/item/CHANGELOG.md). diff --git a/app/account-wizard.go b/app/account-wizard.go new file mode 100644 index 0000000..9c216a1 --- /dev/null +++ b/app/account-wizard.go @@ -0,0 +1,886 @@ +package app + +import ( + "errors" + "fmt" + "net" + "net/url" + "os" + "os/exec" + "regexp" + "strconv" + "strings" + "sync" + + "github.com/emersion/go-message/mail" + "github.com/go-ini/ini" + "golang.org/x/sys/unix" + + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/lib/format" + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/ui" + "git.sr.ht/~rjarry/aerc/lib/xdg" + "git.sr.ht/~rockorager/vaxis" +) + +const ( + CONFIGURE_BASICS = iota + CONFIGURE_SOURCE = iota + CONFIGURE_OUTGOING = iota + CONFIGURE_COMPLETE = iota +) + +type AccountWizard struct { + step int + steps []*ui.Grid + focus int + temporary bool + // CONFIGURE_BASICS + accountName *ui.TextInput + email *ui.TextInput + discovered map[string]string + fullName *ui.TextInput + basics []ui.Interactive + // CONFIGURE_SOURCE + sourceProtocol *Selector + sourceTransport *Selector + + sourceUsername *ui.TextInput + sourcePassword *ui.TextInput + sourceServer *ui.TextInput + sourceStr *ui.Text + sourceUrl url.URL + source []ui.Interactive + // CONFIGURE_OUTGOING + outgoingProtocol *Selector + outgoingTransport *Selector + + outgoingUsername *ui.TextInput + outgoingPassword *ui.TextInput + outgoingServer *ui.TextInput + outgoingStr *ui.Text + outgoingUrl url.URL + outgoingCopyTo *ui.TextInput + outgoing []ui.Interactive + // CONFIGURE_COMPLETE + complete []ui.Interactive +} + +func showPasswordWarning() { + title := "ATTENTION" + text := ` +The Wizard will store your passwords as clear text in: + + ~/.config/aerc/accounts.conf + +It is recommended to remove the clear text passwords and configure +'source-cred-cmd' and 'outgoing-cred-cmd' using your own password store +after the setup. +` + warning := NewSelectorDialog( + title, text, []string{"OK"}, 0, + SelectedAccountUiConfig(), + func(_ string, _ error) { + CloseDialog() + }, + ) + AddDialog(warning) +} + +type configStep struct { + introduction string + labels []string + fields []ui.Drawable + interactive *[]ui.Interactive +} + +func NewConfigStep(intro string, interactive *[]ui.Interactive) configStep { + return configStep{introduction: intro, interactive: interactive} +} + +func (s *configStep) AddField(label string, field ui.Drawable) { + s.labels = append(s.labels, label) + s.fields = append(s.fields, field) + if i, ok := field.(ui.Interactive); ok { + *s.interactive = append(*s.interactive, i) + } +} + +func (s *configStep) Grid() *ui.Grid { + introduction := strings.TrimSpace(s.introduction) + h := strings.Count(introduction, "\n") + 1 + spec := []ui.GridSpec{ + {Strategy: ui.SIZE_EXACT, Size: ui.Const(1)}, // padding + {Strategy: ui.SIZE_EXACT, Size: ui.Const(h)}, // intro text + {Strategy: ui.SIZE_EXACT, Size: ui.Const(1)}, // padding + } + for range s.fields { + spec = append(spec, []ui.GridSpec{ + {Strategy: ui.SIZE_EXACT, Size: ui.Const(1)}, // label + {Strategy: ui.SIZE_EXACT, Size: ui.Const(1)}, // field + {Strategy: ui.SIZE_EXACT, Size: ui.Const(1)}, // padding + }...) + } + justify := ui.GridSpec{Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)} + spec = append(spec, justify) + grid := ui.NewGrid().Rows(spec).Columns([]ui.GridSpec{justify}) + + intro := ui.NewText(introduction, config.Ui.GetStyle(config.STYLE_DEFAULT)) + fill := ui.NewFill(' ', vaxis.Style{}) + + grid.AddChild(fill).At(0, 0) + grid.AddChild(intro).At(1, 0) + grid.AddChild(fill).At(2, 0) + + row := 3 + for i, field := range s.fields { + label := ui.NewText(s.labels[i], config.Ui.GetStyle(config.STYLE_HEADER)) + grid.AddChild(label).At(row, 0) + grid.AddChild(field).At(row+1, 0) + grid.AddChild(fill).At(row+2, 0) + row += 3 + } + + grid.AddChild(fill).At(row, 0) + + return grid +} + +const ( + // protocols + IMAP = "IMAP" + JMAP = "JMAP" + MAILDIR = "Maildir" + MAILDIRPP = "Maildir++" + NOTMUCH = "notmuch" + SMTP = "SMTP" + SENDMAIL = "sendmail" + // transports + SSL_TLS = "SSL/TLS" + OAUTH = "SSL/TLS+OAUTHBEARER" + XOAUTH = "SSL/TLS+XOAUTH2" + STARTTLS = "STARTTLS" + INSECURE = "Insecure" +) + +var ( + sources = []string{IMAP, JMAP, MAILDIR, MAILDIRPP, NOTMUCH} + outgoings = []string{SMTP, JMAP, SENDMAIL} + transports = []string{SSL_TLS, OAUTH, XOAUTH, STARTTLS, INSECURE} +) + +func NewAccountWizard() *AccountWizard { + wizard := &AccountWizard{ + accountName: ui.NewTextInput("", config.Ui).Prompt("> "), + temporary: false, + email: ui.NewTextInput("", config.Ui).Prompt("> "), + fullName: ui.NewTextInput("", config.Ui).Prompt("> "), + sourcePassword: ui.NewTextInput("", config.Ui).Prompt("] ").Password(true), + sourceServer: ui.NewTextInput("", config.Ui).Prompt("> "), + sourceStr: ui.NewText("", config.Ui.GetStyle(config.STYLE_DEFAULT)), + sourceUsername: ui.NewTextInput("", config.Ui).Prompt("> "), + outgoingPassword: ui.NewTextInput("", config.Ui).Prompt("] ").Password(true), + outgoingServer: ui.NewTextInput("", config.Ui).Prompt("> "), + outgoingStr: ui.NewText("", config.Ui.GetStyle(config.STYLE_DEFAULT)), + outgoingUsername: ui.NewTextInput("", config.Ui).Prompt("> "), + outgoingCopyTo: ui.NewTextInput("", config.Ui).Prompt("> "), + + sourceProtocol: NewSelector(sources, 0, config.Ui).Chooser(true), + sourceTransport: NewSelector(transports, 0, config.Ui).Chooser(true), + outgoingProtocol: NewSelector(outgoings, 0, config.Ui).Chooser(true), + outgoingTransport: NewSelector(transports, 0, config.Ui).Chooser(true), + } + + // Autofill some stuff for the user + wizard.email.OnFocusLost(func(_ *ui.TextInput) { + value := wizard.email.String() + if wizard.sourceUsername.String() == "" { + wizard.sourceUsername.Set(value) + } + if wizard.outgoingUsername.String() == "" { + wizard.outgoingUsername.Set(value) + } + wizard.sourceUri() + wizard.outgoingUri() + }) + wizard.sourceProtocol.OnSelect(func(option string) { + wizard.sourceServer.Set("") + wizard.autofill() + wizard.sourceUri() + }) + wizard.sourceServer.OnChange(func(_ *ui.TextInput) { + wizard.sourceUri() + }) + wizard.sourceServer.OnFocusLost(func(_ *ui.TextInput) { + src := wizard.sourceServer.String() + out := wizard.outgoingServer.String() + if out == "" && strings.HasPrefix(src, "imap.") { + out = strings.Replace(src, "imap.", "smtp.", 1) + wizard.outgoingServer.Set(out) + } + wizard.outgoingUri() + }) + wizard.sourceUsername.OnChange(func(_ *ui.TextInput) { + wizard.sourceUri() + }) + wizard.sourceUsername.OnFocusLost(func(_ *ui.TextInput) { + if wizard.outgoingUsername.String() == "" { + wizard.outgoingUsername.Set(wizard.sourceUsername.String()) + wizard.outgoingUri() + } + }) + wizard.sourceTransport.OnSelect(func(option string) { + wizard.sourceUri() + }) + var once sync.Once + wizard.sourcePassword.OnChange(func(_ *ui.TextInput) { + wizard.outgoingPassword.Set(wizard.sourcePassword.String()) + wizard.sourceUri() + wizard.outgoingUri() + }) + wizard.sourcePassword.OnFocusLost(func(_ *ui.TextInput) { + if wizard.sourcePassword.String() != "" { + once.Do(func() { + showPasswordWarning() + }) + } + }) + wizard.outgoingProtocol.OnSelect(func(option string) { + wizard.outgoingServer.Set("") + wizard.autofill() + wizard.outgoingUri() + }) + wizard.outgoingServer.OnChange(func(_ *ui.TextInput) { + wizard.outgoingUri() + }) + wizard.outgoingUsername.OnChange(func(_ *ui.TextInput) { + wizard.outgoingUri() + }) + wizard.outgoingPassword.OnChange(func(_ *ui.TextInput) { + if wizard.outgoingPassword.String() != "" { + once.Do(func() { + showPasswordWarning() + }) + } + wizard.outgoingUri() + }) + wizard.outgoingTransport.OnSelect(func(option string) { + wizard.outgoingUri() + }) + + // CONFIGURE_BASICS + basics := NewConfigStep( + ` +Welcome to aerc! Let's configure your account. + +Key bindings: + + <Tab>, <Down> or <Ctrl+j> Next field + <Shift+Tab>, <Up> or <Ctrl+k> Previous field + <Ctrl+q> Exit aerc +`, + &wizard.basics, + ) + basics.AddField( + "Name for this account? (e.g. 'Personal' or 'Work')", + wizard.accountName, + ) + basics.AddField( + "Full name for outgoing emails? (e.g. 'John Doe')", + wizard.fullName, + ) + basics.AddField( + "Your email address? (e.g. 'john@example.org')", + wizard.email, + ) + basics.AddField("", NewSelector([]string{"Next"}, 0, config.Ui). + OnChoose(func(option string) { + wizard.discoverServices() + wizard.autofill() + wizard.sourceUri() + wizard.outgoingUri() + wizard.advance(option) + }), + ) + + // CONFIGURE_SOURCE + source := NewConfigStep("Configure email source", &wizard.source) + source.AddField("Protocol", wizard.sourceProtocol) + source.AddField("Username", wizard.sourceUsername) + source.AddField("Password", wizard.sourcePassword) + source.AddField( + "Server address (or path to email store)", + wizard.sourceServer, + ) + source.AddField("Transport security", wizard.sourceTransport) + source.AddField("Connection URL", wizard.sourceStr) + source.AddField( + "", NewSelector([]string{"Previous", "Next"}, 1, config.Ui). + OnChoose(wizard.advance), + ) + + // CONFIGURE_OUTGOING + outgoing := NewConfigStep("Configure outgoing mail", &wizard.outgoing) + outgoing.AddField("Protocol", wizard.outgoingProtocol) + outgoing.AddField("Username", wizard.outgoingUsername) + outgoing.AddField("Password", wizard.outgoingPassword) + outgoing.AddField( + "Server address (or path to sendmail)", + wizard.outgoingServer, + ) + outgoing.AddField("Transport security", wizard.outgoingTransport) + outgoing.AddField("Connection URL", wizard.outgoingStr) + outgoing.AddField( + "Copy sent messages to folder (leave empty to disable)", + wizard.outgoingCopyTo, + ) + outgoing.AddField( + "", NewSelector([]string{"Previous", "Next"}, 1, config.Ui). + OnChoose(wizard.advance), + ) + + // CONFIGURE_COMPLETE + complete := NewConfigStep( + fmt.Sprintf(` +Configuration complete! + +You can go back and double check your settings, or choose [Finish] to +save your settings to %s/accounts.conf. + +Make sure to review the contents of this file and read the +aerc-accounts(5) man page for guidance and further tweaking. + +To add another account in the future, run ':new-account'. +`, xdg.TildeHome(xdg.ConfigPath("aerc"))), + &wizard.complete, + ) + complete.AddField( + "", NewSelector([]string{ + "Previous", + "Finish & open tutorial", + "Finish", + }, 1, config.Ui).OnChoose(func(option string) { + switch option { + case "Previous": + wizard.advance("Previous") + case "Finish & open tutorial": + wizard.finish(true) + case "Finish": + wizard.finish(false) + } + }), + ) + + wizard.steps = []*ui.Grid{ + basics.Grid(), source.Grid(), outgoing.Grid(), complete.Grid(), + } + + return wizard +} + +func (wizard *AccountWizard) ConfigureTemporaryAccount(temporary bool) { + wizard.temporary = temporary +} + +func (wizard *AccountWizard) errorFor(d ui.Interactive, err error) { + if d == nil { + PushError(err.Error()) + wizard.Invalidate() + return + } + for step, interactives := range [][]ui.Interactive{ + wizard.basics, + wizard.source, + wizard.outgoing, + } { + for focus, item := range interactives { + if item == d { + wizard.Focus(false) + wizard.step = step + wizard.focus = focus + wizard.Focus(true) + PushError(err.Error()) + wizard.Invalidate() + return + } + } + } +} + +func (wizard *AccountWizard) finish(tutorial bool) { + accountsConf := xdg.ConfigPath("aerc", "accounts.conf") + + // Validation + if wizard.accountName.String() == "" { + wizard.errorFor(wizard.accountName, + errors.New("Account name is required")) + return + } + if wizard.email.String() == "" { + wizard.errorFor(wizard.email, + errors.New("Email address is required")) + return + } + if wizard.sourceServer.String() == "" { + wizard.errorFor(wizard.sourceServer, + errors.New("Email source configuration is required")) + return + } + if wizard.outgoingServer.String() == "" && + wizard.outgoingProtocol.Selected() != JMAP { + wizard.errorFor(wizard.outgoingServer, + errors.New("Outgoing mail configuration is required")) + return + } + switch wizard.sourceProtocol.Selected() { + case MAILDIR, MAILDIRPP, NOTMUCH: + path := xdg.ExpandHome(wizard.sourceServer.String()) + s, err := os.Stat(path) + if err == nil && !s.IsDir() { + err = fmt.Errorf("%s: Not a directory", s.Name()) + } + if err == nil { + err = unix.Access(path, unix.X_OK) + } + if err != nil { + wizard.errorFor(wizard.sourceServer, err) + return + } + } + if wizard.outgoingProtocol.Selected() == SENDMAIL { + path := xdg.ExpandHome(wizard.outgoingServer.String()) + s, err := os.Stat(path) + if err == nil && !s.Mode().IsRegular() { + err = fmt.Errorf("%s: Not a regular file", s.Name()) + } + if err == nil { + err = unix.Access(path, unix.X_OK) + } + if err != nil { + wizard.errorFor(wizard.outgoingServer, err) + return + } + } + + file, err := ini.Load(accountsConf) + if err != nil { + file = ini.Empty() + } + + var sec *ini.Section + if sec, _ = file.GetSection(wizard.accountName.String()); sec != nil { + wizard.errorFor(wizard.accountName, + errors.New("An account by this name already exists")) + return + } + sec, _ = file.NewSection(wizard.accountName.String()) + // these can't fail + _, _ = sec.NewKey("source", wizard.sourceUrl.String()) + _, _ = sec.NewKey("outgoing", wizard.outgoingUrl.String()) + _, _ = sec.NewKey("default", "INBOX") + from := mail.Address{ + Name: wizard.fullName.String(), + Address: wizard.email.String(), + } + _, _ = sec.NewKey("from", format.AddressForHumans(&from)) + if wizard.outgoingCopyTo.String() != "" { + _, _ = sec.NewKey("copy-to", wizard.outgoingCopyTo.String()) + } + + switch wizard.sourceProtocol.Selected() { + case IMAP: + _, _ = sec.NewKey("cache-headers", "true") + case JMAP: + _, _ = sec.NewKey("use-labels", "true") + _, _ = sec.NewKey("cache-state", "true") + _, _ = sec.NewKey("cache-blobs", "false") + case NOTMUCH: + cmd := exec.Command("notmuch", "config", "get", "database.mail_root") + out, err := cmd.Output() + if err == nil { + root := strings.TrimSpace(string(out)) + _, _ = sec.NewKey("maildir-store", xdg.TildeHome(root)) + } + querymap := ini.Empty() + def := querymap.Section("") + cmd = exec.Command("notmuch", "config", "list") + out, err = cmd.Output() + if err == nil { + re := regexp.MustCompile(`(?m)^query\.([^=]+)=(.+)$`) + for _, m := range re.FindAllStringSubmatch(string(out), -1) { + _, _ = def.NewKey(m[1], m[2]) + } + } + if len(def.Keys()) == 0 { + _, _ = def.NewKey("INBOX", "tag:inbox and not tag:archived") + } + if !wizard.temporary { + qmapPath := xdg.ConfigPath("aerc", + wizard.accountName.String()+".qmap") + f, err := os.OpenFile(qmapPath, os.O_WRONLY|os.O_CREATE, 0o600) + if err != nil { + wizard.errorFor(nil, err) + return + } + defer f.Close() + if _, err = querymap.WriteTo(f); err != nil { + wizard.errorFor(nil, err) + return + } + _, _ = sec.NewKey("query-map", xdg.TildeHome(qmapPath)) + } + } + + if !wizard.temporary { + f, err := os.OpenFile(accountsConf, os.O_WRONLY|os.O_CREATE, 0o600) + if err != nil { + wizard.errorFor(nil, err) + return + } + defer f.Close() + if _, err = file.WriteTo(f); err != nil { + wizard.errorFor(nil, err) + return + } + } + + account, err := config.ParseAccountConfig(sec.Name(), sec) + if err != nil { + wizard.errorFor(nil, err) + return + } + config.Accounts = append(config.Accounts, account) + + view, err := NewAccountView(account, nil) + if err != nil { + NewTab(errorScreen(err.Error()), account.Name) + return + } + aerc.accounts[account.Name] = view + NewTab(view, account.Name) + + if tutorial { + name := "aerc-tutorial" + if _, err := os.Stat("./aerc-tutorial.7"); !os.IsNotExist(err) { + // For development + name = "./aerc-tutorial.7" + } + term, err := NewTerminal(exec.Command("man", name)) + if err != nil { + wizard.errorFor(nil, err) + return + } + NewTab(term, "Tutorial") + term.OnClose = func(err error) { + RemoveTab(term, false) + if err != nil { + PushError(err.Error()) + } + } + } + + RemoveTab(wizard, false) +} + +func splitHostPath(server string) (string, string) { + host, path, found := strings.Cut(server, "/") + if found { + path = "/" + path + } + return host, path +} + +func makeURLs(scheme, host, path, user, pass string) (url.URL, url.URL) { + var opaque string + + // If everything is unset, the rendered URL is '<scheme>:'. + // Force a '//' opaque suffix so that it is rendered as '<scheme>://'. + if scheme != "" && host == "" && path == "" && user == "" && pass == "" { + opaque = "//" + } + + uri := url.URL{Scheme: scheme, Host: host, Path: path, Opaque: opaque} + clean := uri + + switch { + case pass != "": + uri.User = url.UserPassword(user, pass) + clean.User = url.UserPassword(user, strings.Repeat("*", len(pass))) + case user != "": + uri.User = url.User(user) + clean.User = url.User(user) + } + + return uri, clean +} + +func (wizard *AccountWizard) sourceUri() url.URL { + host, path := splitHostPath(wizard.sourceServer.String()) + user := wizard.sourceUsername.String() + pass := wizard.sourcePassword.String() + var scheme string + switch wizard.sourceProtocol.Selected() { + case IMAP: + switch wizard.sourceTransport.Selected() { + case STARTTLS: + scheme = "imap" + case INSECURE: + scheme = "imap+insecure" + case OAUTH: + scheme = "imaps+oauthbearer" + case XOAUTH: + scheme = "imaps+xoauth2" + default: + scheme = "imaps" + } + case JMAP: + switch wizard.sourceTransport.Selected() { + case OAUTH: + scheme = "jmap+oauthbearer" + default: + scheme = "jmap" + } + case MAILDIR: + scheme = "maildir" + case MAILDIRPP: + scheme = "maildirpp" + case NOTMUCH: + scheme = "notmuch" + } + switch wizard.sourceProtocol.Selected() { + case MAILDIR, MAILDIRPP, NOTMUCH: + path = host + path + host = "" + user = "" + pass = "" + } + + uri, clean := makeURLs(scheme, host, path, user, pass) + + wizard.sourceStr.Text( + " " + strings.ReplaceAll(clean.String(), "%2A", "*")) + wizard.sourceUrl = uri + return uri +} + +func (wizard *AccountWizard) outgoingUri() url.URL { + host, path := splitHostPath(wizard.outgoingServer.String()) + user := wizard.outgoingUsername.String() + pass := wizard.outgoingPassword.String() + var scheme string + switch wizard.outgoingProtocol.Selected() { + case SMTP: + switch wizard.outgoingTransport.Selected() { + case OAUTH: + scheme = "smtps+oauthbearer" + case XOAUTH: + scheme = "smtps+xoauth2" + case INSECURE: + scheme = "smtp+insecure" + case STARTTLS: + scheme = "smtp" + default: + scheme = "smtps" + } + case JMAP: + switch wizard.outgoingTransport.Selected() { + case OAUTH: + scheme = "jmap+oauthbearer" + default: + scheme = "jmap" + } + case SENDMAIL: + scheme = "" + path = host + path + host = "" + user = "" + pass = "" + } + + uri, clean := makeURLs(scheme, host, path, user, pass) + + wizard.outgoingStr.Text( + " " + strings.ReplaceAll(clean.String(), "%2A", "*")) + wizard.outgoingUrl = uri + return uri +} + +func (wizard *AccountWizard) Invalidate() { + ui.Invalidate() +} + +func (wizard *AccountWizard) Draw(ctx *ui.Context) { + wizard.steps[wizard.step].Draw(ctx) +} + +func (wizard *AccountWizard) getInteractive() []ui.Interactive { + switch wizard.step { + case CONFIGURE_BASICS: + return wizard.basics + case CONFIGURE_SOURCE: + return wizard.source + case CONFIGURE_OUTGOING: + return wizard.outgoing + case CONFIGURE_COMPLETE: + return wizard.complete + } + return nil +} + +func (wizard *AccountWizard) advance(direction string) { + wizard.Focus(false) + if direction == "Next" && wizard.step < len(wizard.steps)-1 { + wizard.step++ + } + if direction == "Previous" && wizard.step > 0 { + wizard.step-- + } + wizard.focus = 0 + wizard.Focus(true) + wizard.Invalidate() +} + +func (wizard *AccountWizard) Focus(focus bool) { + if interactive := wizard.getInteractive(); interactive != nil { + interactive[wizard.focus].Focus(focus) + } +} + +func (wizard *AccountWizard) Event(event vaxis.Event) bool { + interactive := wizard.getInteractive() + if key, ok := event.(vaxis.Key); ok { + switch { + case key.Matches('k', vaxis.ModCtrl), + key.Matches(vaxis.KeyTab, vaxis.ModShift), + key.Matches(vaxis.KeyUp): + if interactive != nil { + interactive[wizard.focus].Focus(false) + wizard.focus-- + if wizard.focus < 0 { + wizard.focus = len(interactive) - 1 + } + interactive[wizard.focus].Focus(true) + } + wizard.Invalidate() + return true + case key.Matches('j', vaxis.ModCtrl), + key.Matches(vaxis.KeyTab), + key.Matches(vaxis.KeyDown): + + if interactive != nil { + interactive[wizard.focus].Focus(false) + wizard.focus++ + if wizard.focus >= len(interactive) { + wizard.focus = 0 + } + interactive[wizard.focus].Focus(true) + } + wizard.Invalidate() + return true + } + } + if interactive != nil { + return interactive[wizard.focus].Event(event) + } + return false +} + +func (wizard *AccountWizard) discoverServices() { + email := wizard.email.String() + if !strings.ContainsRune(email, '@') { + return + } + domain := email[strings.IndexRune(email, '@')+1:] + var wg sync.WaitGroup + type Service struct{ srv, hostport string } + services := make(chan Service) + + for _, service := range []string{"imaps", "imap", "submission", "jmap"} { + wg.Add(1) + go func(srv string) { + defer log.PanicHandler() + defer wg.Done() + _, addrs, err := net.LookupSRV(srv, "tcp", domain) + if err != nil { + log.Tracef("SRV lookup for _%s._tcp.%s failed: %s", + srv, domain, err) + } else if addrs[0].Target != "" && addrs[0].Port > 0 { + services <- Service{ + srv: srv, + hostport: net.JoinHostPort( + strings.TrimSuffix(addrs[0].Target, "."), + strconv.Itoa(int(addrs[0].Port))), + } + } + }(service) + } + go func() { + defer log.PanicHandler() + wg.Wait() + close(services) + }() + + wizard.discovered = make(map[string]string) + for s := range services { + wizard.discovered[s.srv] = s.hostport + } +} + +func (wizard *AccountWizard) autofill() { + if wizard.sourceServer.String() == "" { + switch wizard.sourceProtocol.Selected() { + case IMAP: + if s, ok := wizard.discovered["imaps"]; ok { + wizard.sourceServer.Set(s) + wizard.sourceTransport.Select(SSL_TLS) + } else if s, ok := wizard.discovered["imap"]; ok { + wizard.sourceServer.Set(s) + wizard.sourceTransport.Select(STARTTLS) + } + case JMAP: + if s, ok := wizard.discovered["jmap"]; ok { + s = strings.TrimSuffix(s, ":443") + wizard.sourceServer.Set(s + "/.well-known/jmap") + wizard.sourceTransport.Select(SSL_TLS) + } + case MAILDIR, MAILDIRPP: + wizard.sourceServer.Set("~/mail") + wizard.sourceUsername.Set("") + wizard.sourcePassword.Set("") + case NOTMUCH: + cmd := exec.Command("notmuch", "config", "get", "database.path") + out, err := cmd.Output() + if err == nil { + db := strings.TrimSpace(string(out)) + wizard.sourceServer.Set(xdg.TildeHome(db)) + } else { + wizard.sourceServer.Set("~/mail") + } + wizard.sourceUsername.Set("") + wizard.sourcePassword.Set("") + } + } + if wizard.outgoingServer.String() == "" { + switch wizard.outgoingProtocol.Selected() { + case SMTP: + if s, ok := wizard.discovered["submission"]; ok { + switch { + case strings.HasSuffix(s, ":587"): + wizard.outgoingTransport.Select(SSL_TLS) + case strings.HasSuffix(s, ":465"): + wizard.outgoingTransport.Select(STARTTLS) + default: + wizard.outgoingTransport.Select(INSECURE) + } + wizard.outgoingServer.Set(s) + } + case JMAP: + wizard.outgoingTransport.Select(SSL_TLS) + case SENDMAIL: + wizard.outgoingServer.Set("/usr/sbin/sendmail") + wizard.outgoingUsername.Set("") + wizard.outgoingPassword.Set("") + } + } +} diff --git a/app/account.go b/app/account.go new file mode 100644 index 0000000..262d5f3 --- /dev/null +++ b/app/account.go @@ -0,0 +1,776 @@ +package app + +import ( + "bytes" + "errors" + "fmt" + "strings" + "sync" + "time" + + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/lib" + "git.sr.ht/~rjarry/aerc/lib/hooks" + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/marker" + "git.sr.ht/~rjarry/aerc/lib/pama" + "git.sr.ht/~rjarry/aerc/lib/sort" + "git.sr.ht/~rjarry/aerc/lib/state" + "git.sr.ht/~rjarry/aerc/lib/templates" + "git.sr.ht/~rjarry/aerc/lib/ui" + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker" + "git.sr.ht/~rjarry/aerc/worker/types" + "git.sr.ht/~rockorager/vaxis" +) + +var _ ProvidesMessages = (*AccountView)(nil) + +type AccountView struct { + sync.Mutex + acct *config.AccountConfig + dirlist DirectoryLister + labels []string + grid *ui.Grid + tab *ui.Tab + msglist *MessageList + worker *types.Worker + state state.AccountState + newConn bool // True if this is a first run after a new connection/reconnection + + split *MessageViewer + splitSize int + splitDebounce *time.Timer + splitDir config.SplitDirection + splitLoaded bool + + // Check-mail ticker + ticker *time.Ticker + checkingMail bool +} + +func (acct *AccountView) UiConfig() *config.UIConfig { + if dirlist := acct.Directories(); dirlist != nil { + return dirlist.UiConfig("") + } + return config.Ui.ForAccount(acct.acct.Name) +} + +func NewAccountView( + acct *config.AccountConfig, deferLoop chan struct{}, +) (*AccountView, error) { + view := &AccountView{ + acct: acct, + } + + worker, err := worker.NewWorker(acct.Source, acct.Name) + if err != nil { + SetError(fmt.Sprintf("%s: %s", acct.Name, err)) + log.Errorf("%s: %v", acct.Name, err) + return view, err + } + view.worker = worker + + view.dirlist = NewDirectoryList(acct, worker) + + view.msglist = NewMessageList(view) + + view.Configure() + + go func() { + defer log.PanicHandler() + + if deferLoop != nil { + <-deferLoop + } + + worker.Backend.Run() + }() + + worker.PostAction(&types.Configure{Config: acct}, nil) + worker.PostAction(&types.Connect{}, nil) + view.SetStatus(state.ConnectionActivity("Connecting...")) + if acct.CheckMail.Minutes() > 0 { + view.CheckMailTimer(acct.CheckMail) + } + + return view, nil +} + +func (acct *AccountView) Configure() { + acct.dirlist.OnVirtualNode(func() { + acct.msglist.SetStore(nil) + acct.Invalidate() + }) + sidebar := acct.UiConfig().SidebarWidth + acct.grid = ui.NewGrid().Rows([]ui.GridSpec{ + {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)}, + }).Columns([]ui.GridSpec{ + {Strategy: ui.SIZE_EXACT, Size: func() int { + return sidebar + }}, + {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)}, + }) + if sidebar > 0 { + acct.grid.AddChild(ui.NewBordered(acct.dirlist, ui.BORDER_RIGHT, acct.UiConfig())) + } + acct.grid.AddChild(acct.msglist).At(0, 1) + acct.setTitle() + + // handle splits + if acct.split != nil { + acct.split.Close() + } + splitDirection := acct.splitDir + acct.splitDir = config.SPLIT_NONE + switch splitDirection { + case config.SPLIT_HORIZONTAL: + acct.Split(acct.SplitSize()) + case config.SPLIT_VERTICAL: + acct.Vsplit(acct.SplitSize()) + } +} + +func (acct *AccountView) SetStatus(setters ...state.SetStateFunc) { + for _, fn := range setters { + fn(&acct.state, acct.SelectedDirectory()) + } + acct.UpdateStatus() +} + +func (acct *AccountView) UpdateStatus() { + if acct.isSelected() { + UpdateStatus() + } +} + +func (acct *AccountView) Select() { + for i, widget := range aerc.tabs.TabContent.Children() { + if widget == acct { + aerc.SelectTabIndex(i) + } + } +} + +func (acct *AccountView) PushStatus(status string, expiry time.Duration) { + PushStatus(fmt.Sprintf("%s: %s", acct.acct.Name, status), expiry) +} + +func (acct *AccountView) PushError(err error) { + PushError(fmt.Sprintf("%s: %v", acct.acct.Name, err)) +} + +func (acct *AccountView) PushWarning(warning string) { + PushWarning(fmt.Sprintf("%s: %s", acct.acct.Name, warning)) +} + +func (acct *AccountView) AccountConfig() *config.AccountConfig { + return acct.acct +} + +func (acct *AccountView) Worker() *types.Worker { + return acct.worker +} + +func (acct *AccountView) Name() string { + return acct.acct.Name +} + +func (acct *AccountView) Invalidate() { + ui.Invalidate() +} + +func (acct *AccountView) Draw(ctx *ui.Context) { + acct.grid.Draw(ctx) +} + +func (acct *AccountView) MouseEvent(localX int, localY int, event vaxis.Event) { + acct.grid.MouseEvent(localX, localY, event) +} + +func (acct *AccountView) Focus(focus bool) { + // TODO: Unfocus children I guess +} + +func (acct *AccountView) Directories() DirectoryLister { + return acct.dirlist +} + +func (acct *AccountView) SetDirectories(d DirectoryLister) { + if acct.grid != nil { + acct.grid.ReplaceChild(acct.dirlist, d) + } + acct.dirlist = d +} + +func (acct *AccountView) Labels() []string { + return acct.labels +} + +func (acct *AccountView) Messages() *MessageList { + return acct.msglist +} + +func (acct *AccountView) Store() *lib.MessageStore { + if acct.msglist == nil { + return nil + } + return acct.msglist.Store() +} + +func (acct *AccountView) SelectedAccount() *AccountView { + return acct +} + +func (acct *AccountView) SelectedDirectory() string { + return acct.dirlist.Selected() +} + +func (acct *AccountView) SelectedMessage() (*models.MessageInfo, error) { + if acct.msglist == nil || acct.msglist.Store() == nil { + return nil, errors.New("init in progress") + } + if len(acct.msglist.Store().Uids()) == 0 { + return nil, errors.New("no message selected") + } + msg := acct.msglist.Selected() + if msg == nil { + return nil, errors.New("message not loaded") + } + return msg, nil +} + +func (acct *AccountView) MarkedMessages() ([]models.UID, error) { + if store := acct.Store(); store != nil { + return store.Marker().Marked(), nil + } + return nil, errors.New("no store available") +} + +func (acct *AccountView) SelectedMessagePart() *PartInfo { + return nil +} + +func (acct *AccountView) Terminal() *Terminal { + if acct.split == nil { + return nil + } + + return acct.split.Terminal() +} + +func (acct *AccountView) isSelected() bool { + return acct == SelectedAccount() +} + +func (acct *AccountView) newStore(name string) *lib.MessageStore { + uiConf := acct.dirlist.UiConfig(name) + dir := acct.dirlist.Directory(name) + role := "" + if dir != nil { + role = string(dir.Role) + } + backend := acct.AccountConfig().Backend + store := lib.NewMessageStore(acct.worker, name, + func() *config.UIConfig { + return config.Ui. + ForAccount(acct.Name()). + ForFolder(name) + }, + func(msg *models.MessageInfo) { + err := hooks.RunHook(&hooks.MailReceived{ + Account: acct.Name(), + Backend: backend, + Folder: name, + Role: role, + MsgInfo: msg, + }) + if err != nil { + msg := fmt.Sprintf("mail-received hook: %s", err) + PushError(msg) + } + }, func() { + if uiConf.NewMessageBell { + aerc.Beep() + } + }, func() { + err := hooks.RunHook(&hooks.MailDeleted{ + Account: acct.Name(), + Backend: backend, + Folder: name, + Role: role, + }) + if err != nil { + msg := fmt.Sprintf("mail-deleted hook: %s", err) + PushError(msg) + } + }, func(dest string) { + err := hooks.RunHook(&hooks.MailAdded{ + Account: acct.Name(), + Backend: backend, + Folder: dest, + Role: role, + }) + if err != nil { + msg := fmt.Sprintf("mail-added hook: %s", err) + PushError(msg) + } + }, func(add []string, remove []string) { + err := hooks.RunHook(&hooks.TagModified{ + Account: acct.Name(), + Backend: backend, + Add: add, + Remove: remove, + }) + if err != nil { + msg := fmt.Sprintf("tag-modified hook: %s", err) + PushError(msg) + } + }, func(flagname string) { + err := hooks.RunHook(&hooks.FlagChanged{ + Account: acct.Name(), + Backend: backend, + Folder: acct.SelectedDirectory(), + Role: role, + FlagName: flagname, + }) + if err != nil { + msg := fmt.Sprintf("flag-changed hook: %s", err) + PushError(msg) + } + }, + func(msg *models.MessageInfo) { + acct.updateSplitView(msg) + + auto := false + if c := acct.AccountConfig(); c != nil { + r, ok := c.Params["pama-auto-switch"] + if ok { + if strings.ToLower(r) == "true" { + auto = true + } + } + } + if !auto { + return + } + var name string + if msg != nil && msg.Envelope != nil { + name = pama.FromSubject(msg.Envelope.Subject) + } + pama.DebouncedSwitchProject(name) + }, + ) + store.Configure(acct.SortCriteria(uiConf)) + store.SetMarker(marker.New(store)) + return store +} + +func (acct *AccountView) onMessage(msg types.WorkerMessage) { + msg = acct.worker.ProcessMessage(msg) + switch msg := msg.(type) { + case *types.Done: + switch resp := msg.InResponseTo().(type) { + case *types.Connect, *types.Reconnect: + acct.SetStatus(state.ConnectionActivity("Listing mailboxes...")) + log.Infof("[%s] connected.", acct.acct.Name) + acct.SetStatus(state.SetConnected(true)) + log.Tracef("Listing mailboxes...") + acct.worker.PostAction(&types.ListDirectories{}, nil) + case *types.Disconnect: + acct.dirlist.ClearList() + acct.msglist.SetStore(nil) + log.Infof("[%s] disconnected.", acct.acct.Name) + acct.SetStatus(state.SetConnected(false)) + case *types.OpenDirectory: + acct.dirlist.Update(msg) + if store, ok := acct.dirlist.SelectedMsgStore(); ok { + // If we've opened this dir before, we can re-render it from + // memory while we wait for the update and the UI feels + // snappier. If not, we'll unset the store and show the spinner + // while we download the UID list. + acct.msglist.SetStore(store) + acct.Store().Update(msg.InResponseTo()) + } else { + acct.msglist.SetStore(nil) + } + case *types.CreateDirectory: + store := acct.newStore(resp.Directory) + acct.dirlist.SetMsgStore(&models.Directory{ + Name: resp.Directory, + }, store) + acct.dirlist.Update(msg) + case *types.RemoveDirectory: + acct.dirlist.Update(msg) + case *types.FetchMessageHeaders: + if acct.newConn { + acct.checkMailOnStartup() + } + case *types.ListDirectories: + acct.dirlist.Update(msg) + if dir := acct.dirlist.Selected(); dir != "" { + acct.dirlist.Select(dir) + return + } + // Nothing selected, select based on config + dirs := acct.dirlist.List() + var dir string + for _, _dir := range dirs { + if _dir == acct.acct.Default { + dir = _dir + break + } + } + if dir == "" && len(dirs) > 0 { + dir = dirs[0] + } + if dir != "" { + acct.dirlist.Select(dir) + } + acct.msglist.SetInitDone() + acct.newConn = true + } + case *types.Directory: + store, ok := acct.dirlist.MsgStore(msg.Dir.Name) + if !ok { + store = acct.newStore(msg.Dir.Name) + } + acct.dirlist.SetMsgStore(msg.Dir, store) + case *types.DirectoryInfo: + acct.dirlist.Update(msg) + case *types.DirectoryContents: + if store, ok := acct.dirlist.SelectedMsgStore(); ok { + if acct.msglist.Store() == nil { + acct.msglist.SetStore(store) + } + store.Update(msg) + acct.SetStatus(state.Threading(store.ThreadedView())) + } + if acct.newConn && len(msg.Uids) == 0 { + acct.checkMailOnStartup() + } + case *types.DirectoryThreaded: + if store, ok := acct.dirlist.SelectedMsgStore(); ok { + if acct.msglist.Store() == nil { + acct.msglist.SetStore(store) + } + store.Update(msg) + acct.SetStatus(state.Threading(store.ThreadedView())) + } + if acct.newConn && len(msg.Threads) == 0 { + acct.checkMailOnStartup() + } + case *types.FullMessage: + if store, ok := acct.dirlist.SelectedMsgStore(); ok { + store.Update(msg) + } + case *types.MessageInfo: + if store, ok := acct.dirlist.SelectedMsgStore(); ok { + store.Update(msg) + } + case *types.MessagesDeleted: + if dir := acct.dirlist.SelectedDirectory(); dir != nil { + dir.Exists -= len(msg.Uids) + } + if store, ok := acct.dirlist.SelectedMsgStore(); ok { + store.Update(msg) + } + case *types.MessagesCopied: + acct.updateDirCounts(msg.Destination, msg.Uids) + case *types.MessagesMoved: + acct.updateDirCounts(msg.Destination, msg.Uids) + case *types.LabelList: + acct.labels = msg.Labels + case *types.ConnError: + log.Errorf("[%s] connection error: %v", acct.acct.Name, msg.Error) + acct.SetStatus(state.SetConnected(false)) + acct.PushError(msg.Error) + acct.msglist.SetStore(nil) + acct.worker.PostAction(&types.Reconnect{}, nil) + case *types.Error: + log.Errorf("[%s] unexpected error: %v", acct.acct.Name, msg.Error) + acct.PushError(msg.Error) + } + acct.UpdateStatus() + acct.setTitle() +} + +func (acct *AccountView) updateDirCounts(destination string, uids []models.UID) { + // Only update the destination destDir if it is initialized + if destDir := acct.dirlist.Directory(destination); destDir != nil { + var recent, unseen int + var accurate bool = true + for _, uid := range uids { + // Get the message from the originating store + msg, ok := acct.Store().Messages[uid] + if !ok { + continue + } + // If message that was not yet loaded is copied + if msg == nil { + accurate = false + break + } + seen := msg.Flags.Has(models.SeenFlag) + if msg.Flags.Has(models.RecentFlag) { + recent++ + } + if !seen { + unseen++ + } + } + if accurate { + destDir.Recent += recent + destDir.Unseen += unseen + destDir.Exists += len(uids) + } else { + destDir.Exists += len(uids) + } + } +} + +func (acct *AccountView) SortCriteria(uiConf *config.UIConfig) []*types.SortCriterion { + if uiConf == nil { + return nil + } + if len(uiConf.Sort) == 0 { + return nil + } + criteria, err := sort.GetSortCriteria(uiConf.Sort) + if err != nil { + acct.PushError(fmt.Errorf("ui sort: %w", err)) + return nil + } + return criteria +} + +func (acct *AccountView) GetSortCriteria() []*types.SortCriterion { + return acct.SortCriteria(acct.UiConfig()) +} + +func (acct *AccountView) CheckMail() { + acct.Lock() + defer acct.Unlock() + if acct.checkingMail { + return + } + // Exclude selected mailbox, per IMAP specification + exclude := append(acct.AccountConfig().CheckMailExclude, acct.dirlist.Selected()) //nolint:gocritic // intentional append to different slice + dirs := acct.dirlist.List() + dirs = acct.dirlist.FilterDirs(dirs, acct.AccountConfig().CheckMailInclude, false) + dirs = acct.dirlist.FilterDirs(dirs, exclude, true) + log.Debugf("Checking for new mail on account %s", acct.Name()) + acct.SetStatus(state.ConnectionActivity("Checking for new mail...")) + msg := &types.CheckMail{ + Directories: dirs, + Command: acct.acct.CheckMailCmd, + Timeout: acct.acct.CheckMailTimeout, + } + acct.checkingMail = true + + var cb func(types.WorkerMessage) + cb = func(response types.WorkerMessage) { + dirsMsg, ok := response.(*types.CheckMailDirectories) + if ok { + checkMailMsg := &types.CheckMail{ + Directories: dirsMsg.Directories, + Command: acct.acct.CheckMailCmd, + Timeout: acct.acct.CheckMailTimeout, + } + acct.worker.PostAction(checkMailMsg, cb) + } else { // Done + acct.SetStatus(state.ConnectionActivity("")) + acct.Lock() + acct.checkingMail = false + acct.Unlock() + } + } + acct.worker.PostAction(msg, cb) +} + +// CheckMailReset resets the check-mail timer +func (acct *AccountView) CheckMailReset() { + if acct.ticker != nil { + d := acct.AccountConfig().CheckMail + acct.ticker = time.NewTicker(d) + } +} + +func (acct *AccountView) checkMailOnStartup() { + if acct.AccountConfig().CheckMail.Minutes() > 0 { + acct.newConn = false + acct.CheckMail() + } +} + +func (acct *AccountView) CheckMailTimer(d time.Duration) { + acct.ticker = time.NewTicker(d) + go func() { + defer log.PanicHandler() + for range acct.ticker.C { + if !acct.state.Connected { + continue + } + acct.CheckMail() + } + }() +} + +func (acct *AccountView) closeSplit() { + if acct.split != nil { + acct.split.Close() + } + acct.splitSize = 0 + acct.splitDir = config.SPLIT_NONE + acct.split = nil + acct.grid = ui.NewGrid().Rows([]ui.GridSpec{ + {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)}, + }).Columns([]ui.GridSpec{ + {Strategy: ui.SIZE_EXACT, Size: func() int { + return acct.UiConfig().SidebarWidth + }}, + {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)}, + }) + + acct.grid.AddChild(ui.NewBordered(acct.dirlist, ui.BORDER_RIGHT, acct.UiConfig())) + acct.grid.AddChild(acct.msglist).At(0, 1) + ui.Invalidate() +} + +func (acct *AccountView) updateSplitView(msg *models.MessageInfo) { + uiConf := acct.UiConfig() + if !acct.splitLoaded { + switch uiConf.MessageListSplit.Direction { + case config.SPLIT_HORIZONTAL: + acct.Split(uiConf.MessageListSplit.Size) + case config.SPLIT_VERTICAL: + acct.Vsplit(uiConf.MessageListSplit.Size) + } + acct.splitLoaded = true + } + if acct.splitSize == 0 || !acct.splitLoaded { + return + } + if acct.splitDebounce != nil { + acct.splitDebounce.Stop() + } + fn := func() { + if acct.split != nil { + acct.grid.RemoveChild(acct.split) + acct.split.Close() + } + lib.NewMessageStoreView(msg, false, acct.Store(), CryptoProvider(), DecryptKeys, + func(view lib.MessageView, err error) { + if err != nil { + PushError(err.Error()) + return + } + viewer, err := NewMessageViewer(acct, view) + if err != nil { + PushError(err.Error()) + return + } + acct.split = viewer + switch acct.splitDir { + case config.SPLIT_HORIZONTAL: + acct.grid.AddChild(acct.split).At(1, 1) + case config.SPLIT_VERTICAL: + acct.grid.AddChild(acct.split).At(0, 2) + } + }) + } + acct.splitDebounce = time.AfterFunc(100*time.Millisecond, func() { + ui.QueueFunc(fn) + }) +} + +func (acct *AccountView) SplitSize() int { + return acct.splitSize +} + +func (acct *AccountView) SetSplitSize(n int) { + if n == 0 { + acct.closeSplit() + } + acct.splitSize = n +} + +// Split splits the message list view horizontally. The message list will be n +// rows high. If n is 0, any existing split is removed +func (acct *AccountView) Split(n int) { + acct.SetSplitSize(n) + if acct.splitDir == config.SPLIT_HORIZONTAL || n == 0 { + return + } + acct.splitDir = config.SPLIT_HORIZONTAL + acct.grid = ui.NewGrid().Rows([]ui.GridSpec{ + // Add 1 so that the splitSize is the number of visible messages + {Strategy: ui.SIZE_EXACT, Size: func() int { return acct.SplitSize() + 1 }}, + {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)}, + }).Columns([]ui.GridSpec{ + {Strategy: ui.SIZE_EXACT, Size: func() int { + return acct.UiConfig().SidebarWidth + }}, + {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)}, + }) + + acct.grid.AddChild(ui.NewBordered(acct.dirlist, ui.BORDER_RIGHT, acct.UiConfig())).Span(2, 1) + acct.grid.AddChild(ui.NewBordered(acct.msglist, ui.BORDER_BOTTOM, acct.UiConfig())).At(0, 1) + acct.split, _ = NewMessageViewer(acct, nil) + acct.grid.AddChild(acct.split).At(1, 1) + msg, err := acct.SelectedMessage() + if err != nil { + log.Debugf("split: load message error: %v", err) + } + acct.updateSplitView(msg) +} + +// Vsplit splits the message list view vertically. The message list will be n +// rows wide. If n is 0, any existing split is removed +func (acct *AccountView) Vsplit(n int) { + acct.SetSplitSize(n) + if acct.splitDir == config.SPLIT_VERTICAL || n == 0 { + return + } + acct.splitDir = config.SPLIT_VERTICAL + acct.grid = ui.NewGrid().Rows([]ui.GridSpec{ + {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)}, + }).Columns([]ui.GridSpec{ + {Strategy: ui.SIZE_EXACT, Size: func() int { + return acct.UiConfig().SidebarWidth + }}, + {Strategy: ui.SIZE_EXACT, Size: acct.SplitSize}, + {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)}, + }) + + acct.grid.AddChild(ui.NewBordered(acct.dirlist, ui.BORDER_RIGHT, acct.UiConfig())).At(0, 0) + acct.grid.AddChild(ui.NewBordered(acct.msglist, ui.BORDER_RIGHT, acct.UiConfig())).At(0, 1) + acct.split, _ = NewMessageViewer(acct, nil) + acct.grid.AddChild(acct.split).At(0, 2) + msg, err := acct.SelectedMessage() + if err != nil { + log.Debugf("split: load message error: %v", err) + } + acct.updateSplitView(msg) +} + +// setTitle executes the title template and sets the tab title +func (acct *AccountView) setTitle() { + if acct.tab == nil { + return + } + + data := state.NewDataSetter() + data.SetAccount(acct.acct) + data.SetFolder(acct.Directories().SelectedDirectory()) + data.SetRUE(acct.dirlist.List(), acct.dirlist.GetRUECount) + data.SetState(&acct.state) + + var buf bytes.Buffer + err := templates.Render(acct.UiConfig().TabTitleAccount, &buf, data.Data()) + if err != nil { + acct.PushError(err) + return + } + acct.tab.SetTitle(buf.String()) +} diff --git a/app/aerc.go b/app/aerc.go new file mode 100644 index 0000000..3b8847d --- /dev/null +++ b/app/aerc.go @@ -0,0 +1,962 @@ +package app + +import ( + "context" + "errors" + "fmt" + "io" + "net/url" + "os/exec" + "sort" + "strings" + "time" + "unicode" + + "git.sr.ht/~rjarry/go-opt/v2" + "git.sr.ht/~rockorager/vaxis" + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/emersion/go-message/mail" + + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/lib" + "git.sr.ht/~rjarry/aerc/lib/crypto" + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/ui" + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/types" +) + +type Aerc struct { + accounts map[string]*AccountView + cmd func(string, *config.AccountConfig, *models.MessageInfo) error + cmdHistory lib.History + complete func(ctx context.Context, cmd string) ([]opt.Completion, string) + focused ui.Interactive + grid *ui.Grid + simulating int + statusbar *ui.Stack + statusline *StatusLine + pasting bool + pendingKeys []config.KeyStroke + prompts *ui.Stack + tabs *ui.Tabs + beep func() + dialog ui.DrawableInteractive + + Crypto crypto.Provider +} + +type Choice struct { + Key string + Text string + Command string +} + +func (aerc *Aerc) Init( + crypto crypto.Provider, + cmd func(string, *config.AccountConfig, *models.MessageInfo) error, + complete func(ctx context.Context, cmd string) ([]opt.Completion, string), cmdHistory lib.History, + deferLoop chan struct{}, +) { + tabs := ui.NewTabs(func(d ui.Drawable) *config.UIConfig { + acct := aerc.account(d) + if acct != nil { + return config.Ui.ForAccount(acct.Name()) + } + return config.Ui + }) + + statusbar := ui.NewStack(config.Ui) + statusline := &StatusLine{} + statusbar.Push(statusline) + + grid := ui.NewGrid().Rows([]ui.GridSpec{ + {Strategy: ui.SIZE_EXACT, Size: ui.Const(1)}, + {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)}, + {Strategy: ui.SIZE_EXACT, Size: ui.Const(1)}, + }).Columns([]ui.GridSpec{ + {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)}, + }) + grid.AddChild(tabs.TabStrip) + grid.AddChild(tabs.TabContent).At(1, 0) + grid.AddChild(statusbar).At(2, 0) + + aerc.accounts = make(map[string]*AccountView) + aerc.cmd = cmd + aerc.cmdHistory = cmdHistory + aerc.complete = complete + aerc.grid = grid + aerc.statusbar = statusbar + aerc.statusline = statusline + aerc.prompts = ui.NewStack(config.Ui) + aerc.tabs = tabs + aerc.Crypto = crypto + + for _, acct := range config.Accounts { + view, err := NewAccountView(acct, deferLoop) + if err != nil { + tabs.Add(errorScreen(err.Error()), acct.Name, false) + } else { + aerc.accounts[acct.Name] = view + view.tab = tabs.Add(view, acct.Name, false) + } + } + + if len(config.Accounts) == 0 { + wizard := NewAccountWizard() + wizard.Focus(true) + aerc.NewTab(wizard, "New account", false) + } + + tabs.Select(0) + + tabs.CloseTab = func(index int) { + tab := aerc.tabs.Get(index) + if tab == nil { + return + } + switch content := tab.Content.(type) { + case *AccountView: + return + case *AccountWizard: + return + default: + aerc.RemoveTab(content, true) + } + } + + aerc.showConfigWarnings() +} + +func (aerc *Aerc) showConfigWarnings() { + var dialogs []ui.DrawableInteractive + + callback := func(string, error) { + aerc.CloseDialog() + if len(dialogs) > 0 { + d := dialogs[0] + dialogs = dialogs[1:] + aerc.AddDialog(d) + } + } + + for _, w := range config.Warnings { + dialogs = append(dialogs, NewSelectorDialog( + w.Title, w.Body, []string{"OK"}, 0, + aerc.SelectedAccountUiConfig(), + callback, + )) + } + + callback("", nil) +} + +func (aerc *Aerc) OnBeep(f func()) { + aerc.beep = f +} + +func (aerc *Aerc) Beep() { + if aerc.beep == nil { + log.Warnf("should beep, but no beeper") + return + } + aerc.beep() +} + +func (aerc *Aerc) HandleMessage(msg types.WorkerMessage) { + if acct, ok := aerc.accounts[msg.Account()]; ok { + acct.onMessage(msg) + } +} + +func (aerc *Aerc) Invalidate() { + ui.Invalidate() +} + +func (aerc *Aerc) Focus(focus bool) { + // who cares +} + +func (aerc *Aerc) Draw(ctx *ui.Context) { + if len(aerc.prompts.Children()) > 0 { + previous := aerc.focused + prompt := aerc.prompts.Pop().(*ExLine) + prompt.finish = func() { + aerc.statusbar.Pop() + aerc.focus(previous) + } + + aerc.statusbar.Push(prompt) + aerc.focus(prompt) + } + aerc.grid.Draw(ctx) + if aerc.dialog != nil { + w, h := ctx.Width(), ctx.Height() + if d, ok := aerc.dialog.(Dialog); ok { + xstart, width := d.ContextWidth() + ystart, height := d.ContextHeight() + aerc.dialog.Draw( + ctx.Subcontext(xstart(w), ystart(h), + width(w), height(h))) + } else if w > 8 && h > 4 { + aerc.dialog.Draw(ctx.Subcontext(4, h/2-2, w-8, 4)) + } + } +} + +func (aerc *Aerc) HumanReadableBindings() []string { + var result []string + binds := aerc.getBindings() + format := func(s string) string { + return strings.ReplaceAll(s, "%", "%%") + } + annotate := func(b *config.Binding) string { + if b.Annotation == "" { + return "" + } + return "[" + b.Annotation + "]" + } + fmtStr := "%10s %s %s" + for _, bind := range binds.Bindings { + result = append(result, fmt.Sprintf(fmtStr, + format(config.FormatKeyStrokes(bind.Input)), + format(config.FormatKeyStrokes(bind.Output)), + annotate(bind), + )) + } + if binds.Globals && config.Binds.Global != nil { + for _, bind := range config.Binds.Global.Bindings { + result = append(result, fmt.Sprintf(fmtStr+" (Globals)", + format(config.FormatKeyStrokes(bind.Input)), + format(config.FormatKeyStrokes(bind.Output)), + annotate(bind), + )) + } + } + result = append(result, fmt.Sprintf(fmtStr, + "$ex", + fmt.Sprintf("'%c'", binds.ExKey.Key), "", + )) + result = append(result, fmt.Sprintf(fmtStr, + "Globals", + fmt.Sprintf("%v", binds.Globals), "", + )) + sort.Strings(result) + return result +} + +func (aerc *Aerc) getBindings() *config.KeyBindings { + selectedAccountName := "" + if aerc.SelectedAccount() != nil { + selectedAccountName = aerc.SelectedAccount().acct.Name + } + switch view := aerc.SelectedTabContent().(type) { + case *AccountView: + binds := config.Binds.MessageList.ForAccount(selectedAccountName) + return binds.ForFolder(view.SelectedDirectory()) + case *AccountWizard: + return config.Binds.AccountWizard + case *Composer: + var binds *config.KeyBindings + switch view.Bindings() { + case "compose::editor": + binds = config.Binds.ComposeEditor.ForAccount( + selectedAccountName) + case "compose::review": + binds = config.Binds.ComposeReview.ForAccount( + selectedAccountName) + default: + binds = config.Binds.Compose.ForAccount( + selectedAccountName) + } + return binds.ForFolder(view.SelectedDirectory()) + case *MessageViewer: + var binds *config.KeyBindings + switch view.Bindings() { + case "view::passthrough": + binds = config.Binds.MessageViewPassthrough.ForAccount( + selectedAccountName) + default: + binds = config.Binds.MessageView.ForAccount( + selectedAccountName) + } + return binds.ForFolder(view.SelectedAccount().SelectedDirectory()) + case *Terminal: + return config.Binds.Terminal + default: + return config.Binds.Global + } +} + +func (aerc *Aerc) simulate(strokes []config.KeyStroke) { + aerc.pendingKeys = []config.KeyStroke{} + bindings := aerc.getBindings() + complete := aerc.SelectedAccountUiConfig().CompletionMinChars != config.MANUAL_COMPLETE + aerc.simulating += 1 + + for _, stroke := range strokes { + simulated := vaxis.Key{ + Keycode: stroke.Key, + Modifiers: stroke.Modifiers, + } + if unicode.IsUpper(stroke.Key) { + simulated.Keycode = unicode.ToLower(stroke.Key) + simulated.Modifiers |= vaxis.ModShift + } + // If none of these mods are present, set the text field to + // enable matching keys like ":" + if stroke.Modifiers&vaxis.ModCtrl == 0 && + stroke.Modifiers&vaxis.ModAlt == 0 && + stroke.Modifiers&vaxis.ModSuper == 0 && + stroke.Modifiers&vaxis.ModHyper == 0 { + + simulated.Text = string(stroke.Key) + } + aerc.Event(simulated) + complete = stroke == bindings.CompleteKey + } + aerc.simulating -= 1 + if exline, ok := aerc.focused.(*ExLine); ok { + // we are still focused on the exline, turn on tab complete + exline.TabComplete(func(ctx context.Context, cmd string) ([]opt.Completion, string) { + return aerc.complete(ctx, cmd) + }) + if complete { + // force completion now + exline.Event(vaxis.Key{Keycode: vaxis.KeyTab}) + } + } +} + +func (aerc *Aerc) Event(event vaxis.Event) bool { + if config.General.QuakeMode { + if e, ok := event.(vaxis.Key); ok && e.MatchString("F1") { + ToggleQuake() + return true + } + } + + if aerc.dialog != nil { + return aerc.dialog.Event(event) + } + + if aerc.focused != nil { + return aerc.focused.Event(event) + } + + switch event := event.(type) { + // TODO: more vaxis events handling + case vaxis.Key: + // If we are in a bracketed paste, don't process the keys for + // bindings + if aerc.pasting { + interactive, ok := aerc.SelectedTabContent().(ui.Interactive) + if ok { + return interactive.Event(event) + } + return false + } + aerc.statusline.Expire() + stroke := config.KeyStroke{ + Modifiers: event.Modifiers, + } + switch { + case event.ShiftedCode != 0: + stroke.Key = event.ShiftedCode + stroke.Modifiers &^= vaxis.ModShift + default: + stroke.Key = event.Keycode + } + aerc.pendingKeys = append(aerc.pendingKeys, stroke) + ui.Invalidate() + bindings := aerc.getBindings() + incomplete := false + result, strokes := bindings.GetBinding(aerc.pendingKeys) + switch result { + case config.BINDING_FOUND: + aerc.simulate(strokes) + return true + case config.BINDING_INCOMPLETE: + incomplete = true + case config.BINDING_NOT_FOUND: + } + if bindings.Globals { + result, strokes = config.Binds.Global.GetBinding(aerc.pendingKeys) + switch result { + case config.BINDING_FOUND: + aerc.simulate(strokes) + return true + case config.BINDING_INCOMPLETE: + incomplete = true + case config.BINDING_NOT_FOUND: + } + } + if !incomplete { + aerc.pendingKeys = []config.KeyStroke{} + exKey := bindings.ExKey + if aerc.simulating > 0 { + // Keybindings still use : even if you change the ex key + exKey = config.Binds.Global.ExKey + } + if aerc.isExKey(event, exKey) { + aerc.BeginExCommand("") + return true + } + interactive, ok := aerc.SelectedTabContent().(ui.Interactive) + if ok { + return interactive.Event(event) + } + return false + } + case vaxis.Mouse: + aerc.grid.MouseEvent(event.Col, event.Row, event) + return true + case vaxis.PasteStartEvent: + aerc.pasting = true + interactive, ok := aerc.SelectedTabContent().(ui.Interactive) + if ok { + return interactive.Event(event) + } + return false + case vaxis.PasteEndEvent: + aerc.pasting = false + interactive, ok := aerc.SelectedTabContent().(ui.Interactive) + if ok { + return interactive.Event(event) + } + return false + } + return false +} + +func (aerc *Aerc) SelectedAccount() *AccountView { + return aerc.account(aerc.SelectedTabContent()) +} + +func (aerc *Aerc) Account(name string) (*AccountView, error) { + if acct, ok := aerc.accounts[name]; ok { + return acct, nil + } + return nil, fmt.Errorf("account <%s> not found", name) +} + +func (aerc *Aerc) PrevAccount() (*AccountView, error) { + cur := aerc.SelectedAccount() + if cur == nil { + return nil, fmt.Errorf("no account selected, cannot get prev") + } + for i, conf := range config.Accounts { + if conf.Name == cur.Name() { + i -= 1 + if i == -1 { + i = len(config.Accounts) - 1 + } + conf = config.Accounts[i] + return aerc.Account(conf.Name) + } + } + return nil, fmt.Errorf("no prev account") +} + +func (aerc *Aerc) NextAccount() (*AccountView, error) { + cur := aerc.SelectedAccount() + if cur == nil { + return nil, fmt.Errorf("no account selected, cannot get next") + } + for i, conf := range config.Accounts { + if conf.Name == cur.Name() { + i += 1 + if i == len(config.Accounts) { + i = 0 + } + conf = config.Accounts[i] + return aerc.Account(conf.Name) + } + } + return nil, fmt.Errorf("no next account") +} + +func (aerc *Aerc) AccountNames() []string { + results := make([]string, 0) + for name := range aerc.accounts { + results = append(results, name) + } + return results +} + +func (aerc *Aerc) account(d ui.Drawable) *AccountView { + switch tab := d.(type) { + case *AccountView: + return tab + case *MessageViewer: + return tab.SelectedAccount() + case *Composer: + return tab.Account() + } + return nil +} + +func (aerc *Aerc) SelectedAccountUiConfig() *config.UIConfig { + acct := aerc.SelectedAccount() + if acct == nil { + return config.Ui + } + return acct.UiConfig() +} + +func (aerc *Aerc) SelectedTabContent() ui.Drawable { + tab := aerc.tabs.Selected() + if tab == nil { + return nil + } + return tab.Content +} + +func (aerc *Aerc) SelectedTab() *ui.Tab { + return aerc.tabs.Selected() +} + +func (aerc *Aerc) NewTab(clickable ui.Drawable, name string, background bool) *ui.Tab { + tab := aerc.tabs.Add(clickable, name, background) + aerc.UpdateStatus() + return tab +} + +func (aerc *Aerc) RemoveTab(tab ui.Drawable, closeContent bool) { + aerc.tabs.Remove(tab) + aerc.UpdateStatus() + if content, ok := tab.(ui.Closeable); ok && closeContent { + content.Close() + } +} + +func (aerc *Aerc) ReplaceTab(tabSrc ui.Drawable, tabTarget ui.Drawable, name string, closeSrc bool) { + aerc.tabs.Replace(tabSrc, tabTarget, name) + if content, ok := tabSrc.(ui.Closeable); ok && closeSrc { + content.Close() + } +} + +func (aerc *Aerc) MoveTab(i int, relative bool) { + aerc.tabs.MoveTab(i, relative) +} + +func (aerc *Aerc) PinTab() { + aerc.tabs.PinTab() +} + +func (aerc *Aerc) UnpinTab() { + aerc.tabs.UnpinTab() +} + +func (aerc *Aerc) NextTab() { + aerc.tabs.NextTab() +} + +func (aerc *Aerc) PrevTab() { + aerc.tabs.PrevTab() +} + +func (aerc *Aerc) SelectTab(name string) bool { + ok := aerc.tabs.SelectName(name) + if ok { + aerc.UpdateStatus() + } + return ok +} + +func (aerc *Aerc) SelectTabIndex(index int) bool { + ok := aerc.tabs.Select(index) + if ok { + aerc.UpdateStatus() + } + return ok +} + +func (aerc *Aerc) SelectTabAtOffset(offset int) { + aerc.tabs.SelectOffset(offset) +} + +func (aerc *Aerc) TabNames() []string { + return aerc.tabs.Names() +} + +func (aerc *Aerc) SelectPreviousTab() bool { + return aerc.tabs.SelectPrevious() +} + +func (aerc *Aerc) UpdateStatus() { + if acct := aerc.SelectedAccount(); acct != nil { + aerc.statusline.Update(acct) + } else { + aerc.statusline.Clear() + } +} + +func (aerc *Aerc) SetError(err string) { + aerc.statusline.SetError(err) +} + +func (aerc *Aerc) PushStatus(text string, expiry time.Duration) *StatusMessage { + return aerc.statusline.Push(text, expiry) +} + +func (aerc *Aerc) PushError(text string) *StatusMessage { + return aerc.statusline.PushError(text) +} + +func (aerc *Aerc) PushWarning(text string) *StatusMessage { + return aerc.statusline.PushWarning(text) +} + +func (aerc *Aerc) PushSuccess(text string) *StatusMessage { + return aerc.statusline.PushSuccess(text) +} + +func (aerc *Aerc) focus(item ui.Interactive) { + if aerc.focused == item { + return + } + if aerc.focused != nil { + aerc.focused.Focus(false) + } + aerc.focused = item + interactive, ok := aerc.SelectedTabContent().(ui.Interactive) + if item != nil { + item.Focus(true) + if ok { + interactive.Focus(false) + } + } else if ok { + interactive.Focus(true) + } +} + +func (aerc *Aerc) BeginExCommand(cmd string) { + previous := aerc.focused + var tabComplete func(context.Context, string) ([]opt.Completion, string) + if aerc.simulating != 0 { + // Don't try to draw completions for simulated events + tabComplete = nil + } else { + tabComplete = aerc.complete + } + exline := NewExLine(cmd, func(cmd string) { + err := aerc.cmd(cmd, nil, nil) + if err != nil { + aerc.PushError(err.Error()) + } + // only add to history if this is an unsimulated command, + // ie one not executed from a keybinding + if aerc.simulating == 0 { + aerc.cmdHistory.Add(cmd) + } + }, func() { + aerc.statusbar.Pop() + aerc.focus(previous) + }, tabComplete, aerc.cmdHistory) + aerc.statusbar.Push(exline) + aerc.focus(exline) +} + +func (aerc *Aerc) PushPrompt(prompt *ExLine) { + aerc.prompts.Push(prompt) +} + +func (aerc *Aerc) RegisterPrompt(prompt string, cmd string) { + p := NewPrompt(prompt, func(text string) { + if text != "" { + cmd += " " + opt.QuoteArg(text) + } + err := aerc.cmd(cmd, nil, nil) + if err != nil { + aerc.PushError(err.Error()) + } + }, func(ctx context.Context, cmd string) ([]opt.Completion, string) { + return nil, "" // TODO: completions + }) + aerc.prompts.Push(p) +} + +func (aerc *Aerc) RegisterChoices(choices []Choice) { + cmds := make(map[string]string) + texts := []string{} + for _, c := range choices { + text := fmt.Sprintf("[%s] %s", c.Key, c.Text) + if strings.Contains(c.Text, c.Key) { + text = strings.Replace(c.Text, c.Key, "["+c.Key+"]", 1) + } + texts = append(texts, text) + cmds[c.Key] = c.Command + } + prompt := strings.Join(texts, ", ") + "? " + p := NewPrompt(prompt, func(text string) { + cmd, ok := cmds[text] + if !ok { + return + } + err := aerc.cmd(cmd, nil, nil) + if err != nil { + aerc.PushError(err.Error()) + } + }, func(ctx context.Context, cmd string) ([]opt.Completion, string) { + return nil, "" // TODO: completions + }) + aerc.prompts.Push(p) +} + +func (aerc *Aerc) Command(args []string) error { + switch { + case len(args) == 0: + return nil // noop success, i.e. ping + case strings.HasPrefix(args[0], "mailto:"): + mailto, err := url.Parse(args[0]) + if err != nil { + return err + } + return aerc.mailto(mailto) + case strings.HasPrefix(args[0], "mbox:"): + return aerc.mbox(args[0]) + case strings.HasPrefix(args[0], ":"): + cmdline := args[0] + if len(args) > 1 { + cmdline = opt.QuoteArgs(args...).String() + } + defer ui.Invalidate() + return aerc.cmd(cmdline, nil, nil) + default: + return errors.New("command not understood") + } +} + +func (aerc *Aerc) mailto(addr *url.URL) error { + var subject string + var body string + var acctName string + var attachments []string + h := &mail.Header{} + to, err := mail.ParseAddressList(addr.Opaque) + if err != nil && addr.Opaque != "" { + return fmt.Errorf("Could not parse to: %w", err) + } + h.SetAddressList("to", to) + template := config.Templates.NewMessage + for key, vals := range addr.Query() { + switch strings.ToLower(key) { + case "account": + acctName = strings.Join(vals, "") + case "bcc": + list, err := mail.ParseAddressList(strings.Join(vals, ",")) + if err != nil { + break + } + h.SetAddressList("Bcc", list) + case "body": + body = strings.Join(vals, "\n") + case "cc": + list, err := mail.ParseAddressList(strings.Join(vals, ",")) + if err != nil { + break + } + h.SetAddressList("Cc", list) + case "in-reply-to": + for i, msgID := range vals { + if len(msgID) > 1 && msgID[0] == '<' && + msgID[len(msgID)-1] == '>' { + vals[i] = msgID[1 : len(msgID)-1] + } + } + h.SetMsgIDList("In-Reply-To", vals) + case "subject": + subject = strings.Join(vals, ",") + h.SetText("Subject", subject) + case "template": + template = strings.Join(vals, "") + log.Tracef("template set to %s", template) + case "attach": + for _, path := range vals { + // remove a potential file:// prefix. + attachments = append(attachments, strings.TrimPrefix(path, "file://")) + } + default: + // any other header gets ignored on purpose to avoid control headers + // being injected + } + } + + acct := aerc.SelectedAccount() + if acctName != "" { + if a, ok := aerc.accounts[acctName]; ok && a != nil { + acct = a + } + } + + if acct == nil { + return errors.New("No account selected") + } + + defer ui.Invalidate() + + composer, err := NewComposer(acct, + acct.AccountConfig(), acct.Worker(), + config.Compose.EditHeaders, template, h, nil, + strings.NewReader(body)) + if err != nil { + return err + } + composer.FocusEditor("subject") + title := "New email" + if subject != "" { + title = subject + composer.FocusTerminal() + } + if to == nil { + composer.FocusEditor("to") + } + composer.Tab = aerc.NewTab(composer, title, false) + + for _, file := range attachments { + composer.AddAttachment(file) + } + return nil +} + +func (aerc *Aerc) mbox(source string) error { + acctConf := config.AccountConfig{} + if selectedAcct := aerc.SelectedAccount(); selectedAcct != nil { + acctConf = *selectedAcct.acct + info := fmt.Sprintf("Loading outgoing mbox mail settings from account [%s]", selectedAcct.Name()) + aerc.PushStatus(info, 10*time.Second) + log.Debugf(info) + } else { + acctConf.From = &mail.Address{Address: "user@localhost"} + } + acctConf.Name = "mbox" + acctConf.Source = source + acctConf.Default = "INBOX" + acctConf.Archive = "Archive" + acctConf.Postpone = "Drafts" + acctConf.CopyTo = []string{"Sent"} + + defer ui.Invalidate() + + mboxView, err := NewAccountView(&acctConf, nil) + if err != nil { + aerc.NewTab(errorScreen(err.Error()), acctConf.Name, false) + } else { + aerc.accounts[acctConf.Name] = mboxView + aerc.NewTab(mboxView, acctConf.Name, false) + } + return nil +} + +func (aerc *Aerc) CloseBackends() error { + var returnErr error + for _, acct := range aerc.accounts { + var raw interface{} = acct.worker.Backend + c, ok := raw.(io.Closer) + if !ok { + continue + } + err := c.Close() + if err != nil { + returnErr = err + log.Errorf("Closing backend failed for %s: %v", acct.Name(), err) + } + } + return returnErr +} + +func (aerc *Aerc) AddDialog(d ui.DrawableInteractive) { + aerc.dialog = d + aerc.Invalidate() +} + +func (aerc *Aerc) CloseDialog() { + aerc.dialog = nil + aerc.Invalidate() +} + +func (aerc *Aerc) GetPassword(title string, prompt string) (chText chan string, chErr chan error) { + chText = make(chan string, 1) + chErr = make(chan error, 1) + getPasswd := NewGetPasswd(title, prompt, func(pw string, err error) { + defer func() { + close(chErr) + close(chText) + aerc.CloseDialog() + }() + if err != nil { + chErr <- err + return + } + chErr <- nil + chText <- pw + }) + aerc.AddDialog(getPasswd) + + return +} + +func (aerc *Aerc) DecryptKeys(keys []openpgp.Key, symmetric bool) (b []byte, err error) { + for _, key := range keys { + ident := key.Entity.PrimaryIdentity() + chPass, chErr := aerc.GetPassword("Decrypt PGP private key", + fmt.Sprintf("Enter password for %s (%8X)\nPress <ESC> to cancel", + ident.Name, key.PublicKey.KeyId)) + + for err := range chErr { + if err != nil { + return nil, err + } + pass := <-chPass + err = key.PrivateKey.Decrypt([]byte(pass)) + return nil, err + } + } + return nil, err +} + +// errorScreen is a widget that draws an error in the middle of the context +func errorScreen(s string) ui.Drawable { + errstyle := config.Ui.GetStyle(config.STYLE_ERROR) + text := ui.NewText(s, errstyle).Strategy(ui.TEXT_CENTER) + grid := ui.NewGrid().Rows([]ui.GridSpec{ + {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)}, + {Strategy: ui.SIZE_EXACT, Size: ui.Const(1)}, + {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)}, + }).Columns([]ui.GridSpec{ + {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)}, + }) + grid.AddChild(ui.NewFill(' ', vaxis.Style{})).At(0, 0) + grid.AddChild(text).At(1, 0) + grid.AddChild(ui.NewFill(' ', vaxis.Style{})).At(2, 0) + return grid +} + +func (aerc *Aerc) isExKey(key vaxis.Key, exKey config.KeyStroke) bool { + return key.Matches(exKey.Key, exKey.Modifiers) +} + +// CmdFallbackSearch checks cmds for the first executable available in PATH. An error is +// returned if none are found +func CmdFallbackSearch(cmds []string, silent bool) (string, error) { + var tried []string + for _, cmd := range cmds { + if cmd == "" { + continue + } + params := strings.Split(cmd, " ") + _, err := exec.LookPath(params[0]) + if err != nil { + tried = append(tried, cmd) + if !silent { + warn := fmt.Sprintf("cmd '%s' not found in PATH, using fallback", cmd) + PushWarning(warn) + } + continue + } + return cmd, nil + } + return "", fmt.Errorf("no command found in PATH: %s", tried) +} diff --git a/app/app.go b/app/app.go new file mode 100644 index 0000000..3af6677 --- /dev/null +++ b/app/app.go @@ -0,0 +1,92 @@ +package app + +import ( + "context" + "time" + + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/lib" + "git.sr.ht/~rjarry/aerc/lib/crypto" + "git.sr.ht/~rjarry/aerc/lib/ipc" + "git.sr.ht/~rjarry/aerc/lib/ui" + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/types" + "git.sr.ht/~rjarry/go-opt/v2" + "github.com/ProtonMail/go-crypto/openpgp" +) + +var aerc Aerc + +func Init( + crypto crypto.Provider, + cmd func(string, *config.AccountConfig, *models.MessageInfo) error, + complete func(ctx context.Context, cmd string) ([]opt.Completion, string), history lib.History, + deferLoop chan struct{}, +) { + aerc.Init(crypto, cmd, complete, history, deferLoop) +} + +func Drawable() ui.DrawableInteractive { return &aerc } +func IPCHandler() ipc.Handler { return &aerc } +func Command(args []string) error { return aerc.Command(args) } +func HandleMessage(msg types.WorkerMessage) { aerc.HandleMessage(msg) } + +func CloseBackends() error { return aerc.CloseBackends() } + +func AddDialog(d ui.DrawableInteractive) { aerc.AddDialog(d) } +func CloseDialog() { aerc.CloseDialog() } + +func HumanReadableBindings() []string { + return aerc.HumanReadableBindings() +} + +func Account(name string) (*AccountView, error) { return aerc.Account(name) } +func AccountNames() []string { return aerc.AccountNames() } +func NextAccount() (*AccountView, error) { return aerc.NextAccount() } +func PrevAccount() (*AccountView, error) { return aerc.PrevAccount() } +func SelectedAccount() *AccountView { return aerc.SelectedAccount() } +func SelectedAccountUiConfig() *config.UIConfig { return aerc.SelectedAccountUiConfig() } + +func NextTab() { aerc.NextTab() } +func PrevTab() { aerc.PrevTab() } +func PinTab() { aerc.PinTab() } +func UnpinTab() { aerc.UnpinTab() } +func MoveTab(i int, relative bool) { aerc.MoveTab(i, relative) } +func TabNames() []string { return aerc.TabNames() } +func GetTab(i int) *ui.Tab { return aerc.tabs.Get(i) } +func SelectTab(name string) bool { return aerc.SelectTab(name) } +func SelectPreviousTab() bool { return aerc.SelectPreviousTab() } +func SelectedTab() *ui.Tab { return aerc.SelectedTab() } +func SelectedTabContent() ui.Drawable { return aerc.SelectedTabContent() } +func SelectTabIndex(index int) bool { return aerc.SelectTabIndex(index) } +func SelectTabAtOffset(offset int) { aerc.SelectTabAtOffset(offset) } +func RemoveTab(tab ui.Drawable, closeContent bool) { aerc.RemoveTab(tab, closeContent) } +func NewTab(clickable ui.Drawable, name string) *ui.Tab { + return aerc.NewTab(clickable, name, false) +} + +func NewBackgroundTab(clickable ui.Drawable, name string) *ui.Tab { + return aerc.NewTab(clickable, name, true) +} + +func ReplaceTab(tabSrc ui.Drawable, tabTarget ui.Drawable, name string, closeSrc bool) { + aerc.ReplaceTab(tabSrc, tabTarget, name, closeSrc) +} + +func UpdateStatus() { aerc.UpdateStatus() } +func PushPrompt(prompt *ExLine) { aerc.PushPrompt(prompt) } +func SetError(text string) { aerc.SetError(text) } +func PushError(text string) *StatusMessage { return aerc.PushError(text) } +func PushWarning(text string) *StatusMessage { return aerc.PushWarning(text) } +func PushSuccess(text string) *StatusMessage { return aerc.PushSuccess(text) } +func PushStatus(text string, expiry time.Duration) *StatusMessage { + return aerc.PushStatus(text, expiry) +} + +func RegisterChoices(choices []Choice) { aerc.RegisterChoices(choices) } +func RegisterPrompt(prompt string, cmd string) { aerc.RegisterPrompt(prompt, cmd) } + +func CryptoProvider() crypto.Provider { return aerc.Crypto } +func DecryptKeys(keys []openpgp.Key, symmetric bool) (b []byte, err error) { + return aerc.DecryptKeys(keys, symmetric) +} diff --git a/app/authinfo.go b/app/authinfo.go new file mode 100644 index 0000000..4e4e6e0 --- /dev/null +++ b/app/authinfo.go @@ -0,0 +1,86 @@ +package app + +import ( + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/lib/auth" + "git.sr.ht/~rjarry/aerc/lib/ui" + "git.sr.ht/~rockorager/vaxis" + "github.com/mattn/go-runewidth" +) + +type AuthInfo struct { + authdetails *auth.Details + showInfo bool + uiConfig *config.UIConfig +} + +func NewAuthInfo(auth *auth.Details, showInfo bool, uiConfig *config.UIConfig) *AuthInfo { + return &AuthInfo{authdetails: auth, showInfo: showInfo, uiConfig: uiConfig} +} + +func (a *AuthInfo) Draw(ctx *ui.Context) { + defaultStyle := a.uiConfig.GetStyle(config.STYLE_DEFAULT) + ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', defaultStyle) + var text string + switch { + case a.authdetails == nil: + text = "(no header)" + ctx.Printf(0, 0, defaultStyle, "%s", text) + case a.authdetails.Err != nil: + style := a.uiConfig.GetStyle(config.STYLE_ERROR) + text = a.authdetails.Err.Error() + ctx.Printf(0, 0, style, "%s", text) + default: + checkBounds := func(x int) bool { + return x < ctx.Width() + } + setResult := func(result auth.Result) (string, vaxis.Style) { + switch result { + case auth.ResultNone: + return "none", defaultStyle + case auth.ResultNeutral: + return "neutral", a.uiConfig.GetStyle(config.STYLE_WARNING) + case auth.ResultPolicy: + return "policy", a.uiConfig.GetStyle(config.STYLE_WARNING) + case auth.ResultPass: + return "✓", a.uiConfig.GetStyle(config.STYLE_SUCCESS) + case auth.ResultFail: + return "✗", a.uiConfig.GetStyle(config.STYLE_ERROR) + default: + return string(result), a.uiConfig.GetStyle(config.STYLE_ERROR) + } + } + x := 1 + for i := 0; i < len(a.authdetails.Results); i++ { + if checkBounds(x) { + text, style := setResult(a.authdetails.Results[i]) + if i > 0 { + text = " " + text + } + x += ctx.Printf(x, 0, style, "%s", text) + } + } + if a.showInfo { + infoText := "" + for i := 0; i < len(a.authdetails.Infos); i++ { + if i > 0 { + infoText += "," + } + infoText += a.authdetails.Infos[i] + if reason := a.authdetails.Reasons[i]; reason != "" { + infoText += reason + } + } + if checkBounds(x) && infoText != "" { + if trunc := ctx.Width() - x - 3; trunc > 0 { + text = runewidth.Truncate(infoText, trunc, "…") + ctx.Printf(x, 0, defaultStyle, " (%s)", text) + } + } + } + } +} + +func (a *AuthInfo) Invalidate() { + ui.Invalidate() +} diff --git a/app/compose.go b/app/compose.go new file mode 100644 index 0000000..fb07f94 --- /dev/null +++ b/app/compose.go @@ -0,0 +1,1995 @@ +package app + +import ( + "bufio" + "bytes" + "fmt" + "io" + "net/textproto" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/emersion/go-message/mail" + "github.com/mattn/go-runewidth" + "github.com/pkg/errors" + + "git.sr.ht/~rjarry/aerc/commands/mode" + "git.sr.ht/~rjarry/aerc/completer" + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/lib" + "git.sr.ht/~rjarry/aerc/lib/format" + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/send" + "git.sr.ht/~rjarry/aerc/lib/state" + "git.sr.ht/~rjarry/aerc/lib/templates" + "git.sr.ht/~rjarry/aerc/lib/ui" + "git.sr.ht/~rjarry/aerc/lib/xdg" + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/types" + "git.sr.ht/~rockorager/vaxis" +) + +type Composer struct { + sync.Mutex + editors map[string]*headerEditor // indexes in lower case (from / cc / bcc) + header *mail.Header + parent *models.OriginalMail // parent of current message, only set if reply + + acctConfig *config.AccountConfig + acct *AccountView + seldir string + + attachments []lib.Attachment + editor *Terminal + email *os.File + grid atomic.Value + heditors atomic.Value // from, to, cc display a user can jump to + review *reviewMessage + worker *types.Worker + completer *completer.Completer + crypto *cryptoStatus + sign bool + encrypt bool + attachKey bool + editHeaders bool + + layout HeaderLayout + focusable []ui.MouseableDrawableInteractive + focused int + sent bool + archive string + + recalledFrom string + postponed bool + + onClose []func(ti *Composer) + + width int + + textParts []*lib.Part + Tab *ui.Tab +} + +func NewComposer( + acct *AccountView, acctConfig *config.AccountConfig, + worker *types.Worker, editHeaders bool, template string, + h *mail.Header, orig *models.OriginalMail, body io.Reader, +) (*Composer, error) { + if h == nil { + h = new(mail.Header) + } + + email, err := os.CreateTemp("", "aerc-compose-*.eml") + if err != nil { + // TODO: handle this better + return nil, err + } + + c := &Composer{ + acct: acct, + acctConfig: acctConfig, + seldir: acct.Directories().Selected(), + header: h, + parent: orig, + email: email, + worker: worker, + // You have to backtab to get to "From", since you usually don't edit it + focused: 1, + completer: nil, + + editHeaders: editHeaders, + } + + data := state.NewDataSetter() + data.SetAccount(acct.acct) + data.SetFolder(acct.Directories().SelectedDirectory()) + data.SetHeaders(h, orig) + data.SetComposer(c) + if err := c.addTemplate(template, data.Data(), body); err != nil { + return nil, err + } + if err := c.setupFor(acct); err != nil { + return nil, err + } + + if err := c.ShowTerminal(editHeaders); err != nil { + return nil, err + } + + mode.NoQuit() + + return c, nil +} + +func (c *Composer) SelectedDirectory() string { + return c.seldir +} + +func (c *Composer) Parent() *models.OriginalMail { + return c.parent +} + +func (c *Composer) SwitchAccount(newAcct *AccountView) error { + // sync the header with the editors + for _, editor := range c.editors { + editor.storeValue() + } + // ensure that from header is updated, so remove it + c.header.Del("from") + c.header.Del("message-id") + // update entire composer with new the account + if err := c.setupFor(newAcct); err != nil { + return err + } + // sync the header with the editors + for _, editor := range c.editors { + editor.loadValue() + } + c.resetReview() + c.Invalidate() + log.Debugf("account successfully switched") + return nil +} + +func (c *Composer) setupFor(view *AccountView) error { + c.Lock() + defer c.Unlock() + // set new account + c.acct = view + c.worker = view.Worker() + c.acctConfig = c.acct.AccountConfig() + // Set from header if not already in header + if fl, err := c.header.AddressList("from"); err != nil || fl == nil { + c.header.SetAddressList("from", []*mail.Address{view.acct.From}) + } + if !c.header.Has("to") { + c.header.SetAddressList("to", make([]*mail.Address, 0)) + } + if !c.header.Has("subject") { + c.header.SetSubject("") + } + + // update completer + cmd := view.acct.AddressBookCmd + if cmd == "" { + cmd = config.Compose.AddressBookCmd + } + cmpl := completer.New(cmd, func(err error) { + PushError( + fmt.Sprintf("could not complete header: %v", err)) + log.Errorf("could not complete header: %v", err) + }) + c.completer = cmpl + + // if editor already exists, we have to get it from the focusable slice + // because this will be rebuild during buildComposeHeader() + var focusEditor ui.MouseableDrawableInteractive + if c.editor != nil && len(c.focusable) > 0 { + focusEditor = c.focusable[len(c.focusable)-1] + } + + // rebuild editors and focusable slice + c.buildComposeHeader(cmpl) + + // restore the editor in the focusable list + if focusEditor != nil { + c.focusable = append(c.focusable, focusEditor) + } + if c.focused >= len(c.focusable) { + c.focused = len(c.focusable) - 1 + } + + // update the crypto parts + c.crypto = nil + c.sign = false + if c.acct.acct.PgpAutoSign { + err := c.SetSign(true) + log.Warnf("failed to enable message signing: %v", err) + } + c.encrypt = false + if c.acct.acct.PgpOpportunisticEncrypt { + c.SetEncrypt(true) + } + err := c.updateCrypto() + if err != nil { + log.Warnf("failed to update crypto: %v", err) + } + + // redraw the grid + c.updateGrid() + + return nil +} + +func (c *Composer) buildComposeHeader(cmpl *completer.Completer) { + c.layout = config.Compose.HeaderLayout + c.editors = make(map[string]*headerEditor) + c.focusable = make([]ui.MouseableDrawableInteractive, 0) + uiConfig := c.acct.UiConfig() + + for i, row := range c.layout { + for j, h := range row { + h = strings.ToLower(h) + c.layout[i][j] = h // normalize to lowercase + e := newHeaderEditor(h, c.header, uiConfig) + if uiConfig.CompletionPopovers { + e.input.TabComplete( + cmpl.ForHeader(h), + uiConfig.CompletionDelay, + uiConfig.CompletionMinChars, + &config.Binds.Compose.CompleteKey, + ) + } + c.editors[h] = e + switch h { + case "from": + // Prepend From to support backtab + c.focusable = append([]ui.MouseableDrawableInteractive{e}, c.focusable...) + default: + c.focusable = append(c.focusable, e) + } + e.OnChange(func() { + c.setTitle() + ui.Invalidate() + }) + e.OnFocusLost(func() { + c.PrepareHeader() //nolint:errcheck // tab title only, fine if it's not valid yet + c.setTitle() + ui.Invalidate() + }) + } + } + + // Add Cc/Bcc editors to layout if present in header and not already visible + for _, h := range []string{"cc", "bcc"} { + if c.header.Has(h) { + if _, ok := c.editors[h]; !ok { + e := newHeaderEditor(h, c.header, uiConfig) + if uiConfig.CompletionPopovers { + e.input.TabComplete( + cmpl.ForHeader(h), + uiConfig.CompletionDelay, + uiConfig.CompletionMinChars, + &config.Binds.Compose.CompleteKey, + ) + } + c.editors[h] = e + c.focusable = append(c.focusable, e) + c.layout = append(c.layout, []string{h}) + } + } + } + + // load current header values into all editors + for _, e := range c.editors { + e.loadValue() + } +} + +func (c *Composer) headerOrder() []string { + var order []string + for _, row := range c.layout { + order = append(order, row...) + } + return order +} + +func (c *Composer) SetSent(archive string) { + c.sent = true + c.archive = archive +} + +func (c *Composer) Sent() bool { + return c.sent +} + +func (c *Composer) SetPostponed() { + c.postponed = true +} + +func (c *Composer) Postponed() bool { + return c.postponed +} + +func (c *Composer) SetRecalledFrom(folder string) { + c.recalledFrom = folder +} + +func (c *Composer) RecalledFrom() string { + return c.recalledFrom +} + +func (c *Composer) Archive() string { + return c.archive +} + +func (c *Composer) SetAttachKey(attach bool) error { + if c.crypto == nil { + if err := c.updateCrypto(); err != nil { + return err + } + } + if !attach { + name := c.crypto.signKey + ".asc" + found := false + for _, a := range c.attachments { + if a.Name() == name { + found = true + } + } + if found { + err := c.DeleteAttachment(name) + if err != nil { + return fmt.Errorf("failed to delete attachment '%s: %w", name, err) + } + } + } + if attach { + var s string + var err error + if c.crypto.signKey == "" { + if c.acctConfig.PgpKeyId != "" { + s = c.acctConfig.PgpKeyId + } else { + s = c.acctConfig.From.Address + } + c.crypto.signKey, err = CryptoProvider().GetSignerKeyId(s) + if err != nil { + return err + } + } + + r, err := CryptoProvider().ExportKey(c.crypto.signKey) + if err != nil { + return err + } + + newPart, err := lib.NewPart( + "application/pgp-keys", + map[string]string{"charset": "UTF-8"}, + r, + ) + if err != nil { + return err + } + c.attachments = append(c.attachments, + lib.NewPartAttachment( + newPart, + c.crypto.signKey+".asc", + ), + ) + + } + + c.attachKey = attach + + c.resetReview() + return nil +} + +func (c *Composer) AttachKey() bool { + return c.attachKey +} + +func (c *Composer) SetSign(sign bool) error { + c.sign = sign + err := c.updateCrypto() + if err != nil { + c.sign = !sign + return fmt.Errorf("Cannot sign message: %w", err) + } + if c.acct.acct.PgpAttachKey { + if err := c.SetAttachKey(sign); err != nil { + return err + } + } + return nil +} + +func (c *Composer) Sign() bool { + return c.sign +} + +func (c *Composer) SetEncrypt(encrypt bool) *Composer { + if !encrypt { + c.encrypt = encrypt + err := c.updateCrypto() + if err != nil { + log.Warnf("failed to update crypto: %v", err) + } + return c + } + // Check on any attempt to encrypt, and any lost focus of "to", "cc", or + // "bcc" field. Use OnFocusLost instead of OnChange to limit keyring checks + c.encrypt = c.checkEncryptionKeys("") + if c.crypto.setEncOneShot { + // Prevent registering a lot of callbacks + c.OnFocusLost("to", c.checkEncryptionKeys) + c.OnFocusLost("cc", c.checkEncryptionKeys) + c.OnFocusLost("bcc", c.checkEncryptionKeys) + c.crypto.setEncOneShot = false + } + return c +} + +func (c *Composer) Encrypt() bool { + return c.encrypt +} + +func (c *Composer) updateCrypto() error { + if c.crypto == nil { + uiConfig := c.acct.UiConfig() + c.crypto = newCryptoStatus(uiConfig) + } + if c.sign { + cp := CryptoProvider() + s, err := c.Signer() + if err != nil { + return errors.Wrap(err, "Signer") + } + c.crypto.signKey, err = cp.GetSignerKeyId(s) + if err != nil { + return err + } + } + + st := "" + switch { + case c.sign && c.encrypt: + st = fmt.Sprintf("Sign (%s) & Encrypt", c.crypto.signKey) + case c.sign: + st = fmt.Sprintf("Sign (%s)", c.crypto.signKey) + case c.encrypt: + st = "Encrypt" + } + c.crypto.status.Text(st) + + c.updateGrid() + + return nil +} + +func (c *Composer) writeEml(reader io.Reader) error { + // .eml files must always use '\r\n' line endings, but some editors + // don't support these, so if they are using one of those, the + // line-endings are transformed + lineEnding := "\r\n" + if config.Compose.LFEditor { + lineEnding = "\n" + } + + scanner := bufio.NewScanner(reader) + for scanner.Scan() { + _, err := c.email.WriteString(scanner.Text() + lineEnding) + if err != nil { + return err + } + } + if scanner.Err() != nil { + return scanner.Err() + } + return c.email.Sync() +} + +// Note: this does not reload the editor. You must call this before the first +// Draw() call. +func (c *Composer) setContents(reader io.Reader) error { + _, err := c.email.Seek(0, io.SeekStart) + if err != nil { + return err + } + err = c.email.Truncate(0) + if err != nil { + return err + } + lineEnding := "\r\n" + if config.Compose.LFEditor { + lineEnding = "\n" + } + + if c.editHeaders { + for _, h := range c.headerOrder() { + var value string + switch h { + case "to", "from", "cc", "bcc": + addresses, err := c.header.AddressList(h) + if err != nil { + log.Warnf("header.AddressList: %s", err) + value, err = c.header.Text(h) + if err != nil { + log.Warnf("header.Text: %s", err) + value = c.header.Get(h) + } + } else { + addr := make([]string, 0, len(addresses)) + for _, a := range addresses { + addr = append(addr, format.AddressForHumans(a)) + } + value = strings.Join(addr, ","+lineEnding+"\t") + } + default: + value, err = c.header.Text(h) + if err != nil { + log.Warnf("header.Text: %s", err) + value = c.header.Get(h) + } + } + key := textproto.CanonicalMIMEHeaderKey(h) + + var sep string + if value == "" { + sep = ":" + } else { + sep = ": " + } + _, err = fmt.Fprintf(c.email, "%s%s%s%s", key, sep, value, lineEnding) + if err != nil { + return err + } + } + _, err = c.email.WriteString(lineEnding) + if err != nil { + return err + } + } + return c.writeEml(reader) +} + +func (c *Composer) AppendPart(mimetype string, params map[string]string, body io.Reader) error { + if !strings.HasPrefix(mimetype, "text") { + return fmt.Errorf("can only append text mimetypes") + } + for _, part := range c.textParts { + if part.MimeType == mimetype { + return fmt.Errorf("%s part already exists", mimetype) + } + } + newPart, err := lib.NewPart(mimetype, params, body) + if err != nil { + return err + } + c.textParts = append(c.textParts, newPart) + c.resetReview() + return nil +} + +func (c *Composer) RemovePart(mimetype string) error { + if mimetype == "text/plain" { + return fmt.Errorf("cannot remove text/plain parts") + } + for i, part := range c.textParts { + if part.MimeType != mimetype { + continue + } + c.textParts = append(c.textParts[:i], c.textParts[i+1:]...) + c.resetReview() + return nil + } + return fmt.Errorf("%s part not found", mimetype) +} + +func (c *Composer) addTemplate( + template string, data models.TemplateData, body io.Reader, +) error { + var readers []io.Reader + + if template != "" { + templateText, err := templates.ParseTemplateFromFile( + template, config.Templates.TemplateDirs, data) + if err != nil { + return err + } + readers = append(readers, templateText) + } + if body != nil { + if len(readers) == 0 { + readers = append(readers, bytes.NewReader([]byte("\r\n"))) + } + readers = append(readers, body) + } + if len(readers) == 0 { + return nil + } + + buf, err := io.ReadAll(io.MultiReader(readers...)) + if err != nil { + return err + } + + mr, err := mail.CreateReader(bytes.NewReader(buf)) + if err != nil { + // no headers in the template nor body + return c.setContents(bytes.NewReader(buf)) + } + + // copy the headers contained in the template to the compose headers + hf := mr.Header.Fields() + for hf.Next() { + c.header.Set(hf.Key(), hf.Value()) + } + + part, err := mr.NextPart() + if err != nil { + return fmt.Errorf("NextPart: %w", err) + } + + return c.setContents(part.Body) +} + +func (c *Composer) GetBody() (*bytes.Buffer, error) { + _, err := c.email.Seek(0, io.SeekStart) + if err != nil { + return nil, err + } + scanner := bufio.NewScanner(c.email) + if c.editHeaders { + // skip headers + for scanner.Scan() { + if scanner.Text() == "" { + break // stop on first empty line + } + } + } + // .eml files must always use '\r\n' line endings + buf := new(bytes.Buffer) + for scanner.Scan() { + buf.WriteString(scanner.Text() + "\r\n") + } + err = scanner.Err() + if err != nil { + return nil, err + } + return buf, nil +} + +func (c *Composer) FocusTerminal() *Composer { + c.Lock() + defer c.Unlock() + return c.focusTerminalPriv() +} + +func (c *Composer) focusTerminalPriv() *Composer { + if c.editor == nil { + return c + } + c.focusActiveWidget(false) + c.focused = len(c.focusable) - 1 + c.focusActiveWidget(true) + return c +} + +// OnHeaderChange registers an OnChange callback for the specified header. +func (c *Composer) OnHeaderChange(header string, fn func(subject string)) { + if editor, ok := c.editors[strings.ToLower(header)]; ok { + editor.OnChange(func() { + fn(editor.input.String()) + }) + } +} + +// OnFocusLost registers an OnFocusLost callback for the specified header. +func (c *Composer) OnFocusLost(header string, fn func(input string) bool) { + if editor, ok := c.editors[strings.ToLower(header)]; ok { + editor.OnFocusLost(func() { + fn(editor.input.String()) + }) + } +} + +func (c *Composer) OnClose(fn func(composer *Composer)) { + c.onClose = append(c.onClose, fn) +} + +func (c *Composer) Terminal() *Terminal { + return c.editor +} + +func (c *Composer) Draw(ctx *ui.Context) { + c.setTitle() + c.width = ctx.Width() + c.grid.Load().(*ui.Grid).Draw(ctx) +} + +func (c *Composer) Invalidate() { + ui.Invalidate() +} + +func (c *Composer) Close() { + for _, onClose := range c.onClose { + onClose(c) + } + if c.email != nil { + path := c.email.Name() + c.email.Close() + os.Remove(path) + c.email = nil + } + if c.editor != nil { + c.editor.Destroy() + c.editor = nil + } + mode.NoQuitDone() +} + +func (c *Composer) Bindings() string { + c.Lock() + defer c.Unlock() + switch c.editor { + case nil: + return "compose::review" + case c.focusedWidget(): + return "compose::editor" + default: + return "compose" + } +} + +func (c *Composer) focusedWidget() ui.MouseableDrawableInteractive { + if c.focused < 0 || c.focused >= len(c.focusable) { + return nil + } + return c.focusable[c.focused] +} + +func (c *Composer) focusActiveWidget(focus bool) { + if w := c.focusedWidget(); w != nil { + w.Focus(focus) + } +} + +func (c *Composer) Event(event vaxis.Event) bool { + c.Lock() + defer c.Unlock() + if w := c.focusedWidget(); c.editor != nil && w != nil { + return w.Event(event) + } + return false +} + +func (c *Composer) MouseEvent(localX int, localY int, event vaxis.Event) { + c.Lock() + for _, e := range c.focusable { + he, ok := e.(*headerEditor) + if ok && he.focused { + he.focused = false + } + } + c.Unlock() + c.grid.Load().(*ui.Grid).MouseEvent(localX, localY, event) + c.Lock() + defer c.Unlock() + for i, e := range c.focusable { + he, ok := e.(*headerEditor) + if ok && he.focused { + if c.editor == nil { + he.focused = false + } else { + c.focusActiveWidget(false) + c.focused = i + c.focusActiveWidget(true) + } + return + } + } +} + +func (c *Composer) Focus(focus bool) { + c.Lock() + if c.editor != nil { + c.focusActiveWidget(focus) + } + c.Unlock() +} + +func (c *Composer) Show(visible bool) { + c.Lock() + if w := c.focusedWidget(); w != nil && c.editor != nil { + if vis, ok := w.(ui.Visible); ok { + vis.Show(visible) + } + } + c.Unlock() +} + +func (c *Composer) Config() *config.AccountConfig { + return c.acctConfig +} + +func (c *Composer) Account() *AccountView { + return c.acct +} + +func (c *Composer) Worker() *types.Worker { + return c.worker +} + +// PrepareHeader finalizes the header, adding the value from the editors +func (c *Composer) PrepareHeader() (*mail.Header, error) { + for _, editor := range c.editors { + editor.storeValue() + } + + // control headers not normally set by the user + // repeated calls to PrepareHeader should be a noop + if !c.header.Has("Message-Id") { + froms, err := c.header.AddressList("from") + if err != nil { + return nil, err + } + if len(froms) == 0 { + return nil, fmt.Errorf("no valid From address found") + } + hostname, err := send.GetMessageIdHostname( + c.acctConfig.SendWithHostname, froms[0]) + if err != nil { + return nil, err + } + if err := c.header.GenerateMessageIDWithHostname(hostname); err != nil { + return nil, err + } + } + + // update the "Date" header every time PrepareHeader is called + if c.acctConfig.SendAsUTC { + c.header.SetDate(time.Now().UTC()) + } else { + c.header.SetDate(time.Now()) + } + + return c.header, nil +} + +func (c *Composer) parseEmbeddedHeader() (*mail.Header, error) { + _, err := c.email.Seek(0, io.SeekStart) + if err != nil { + return nil, errors.Wrap(err, "Seek") + } + + buf := bytes.NewBuffer([]byte{}) + _, err = io.Copy(buf, c.email) + if err != nil { + return nil, fmt.Errorf("mail.ReadMessageCopy: %w", err) + } + if config.Compose.LFEditor { + bytes.ReplaceAll(buf.Bytes(), []byte{'\n'}, []byte{'\r', '\n'}) + } + + msg, err := mail.CreateReader(buf) + if errors.Is(err, io.EOF) { // completely empty + h := mail.HeaderFromMap(make(map[string][]string)) + return &h, nil + } else if err != nil { + return nil, fmt.Errorf("mail.ReadMessage: %w", err) + } + + // merge repeated to, cc, and bcc headers into a single one of each + for _, key := range []string{"To", "Cc", "Bcc"} { + fields := msg.Header.FieldsByKey(key) + if fields.Len() <= 1 { + continue + } + var addrs []*mail.Address + for fields.Next() { + if strings.TrimSpace(fields.Value()) == "" { + continue + } + al, err := mail.ParseAddressList(fields.Value()) + if err != nil { + return nil, fmt.Errorf( + "%s: cannot parse address list: %w", key, err) + } + addrs = append(addrs, al...) + } + msg.Header.SetAddressList(key, addrs) + PushWarning(fmt.Sprintf( + "Multiple %s headers found; merged in a single one.", key)) + } + + return &msg.Header, nil +} + +func getRecipientsEmail(c *Composer) ([]string, error) { + h, err := c.PrepareHeader() + if err != nil { + return nil, errors.Wrap(err, "PrepareHeader") + } + + // collect all 'recipients' from header (to:, cc:, bcc:) + rcpts := make(map[string]bool) + for _, key := range []string{"to", "cc", "bcc"} { + list, err := h.AddressList(key) + if err != nil { + continue + } + for _, entry := range list { + if entry != nil { + rcpts[entry.Address] = true + } + } + } + + // return email addresses as string slice + results := []string{} + for email := range rcpts { + results = append(results, email) + } + return results, nil +} + +func (c *Composer) Signer() (string, error) { + signer := "" + + if c.acctConfig.PgpKeyId != "" { + // get key from explicitly set keyid + signer = c.acctConfig.PgpKeyId + } else { + // get signer from `from` header + from, err := c.header.AddressList("from") + if err != nil { + return "", err + } + + if len(from) > 0 { + signer = from[0].Address + } else { + // fall back to address from config + signer = c.acctConfig.From.Address + } + } + + return signer, nil +} + +func (c *Composer) WriteMessage(header *mail.Header, writer io.Writer) error { + if c.sign || c.encrypt { + + var signedHeader mail.Header + signedHeader.SetContentType("text/plain", nil) + + var buf bytes.Buffer + var cleartext io.WriteCloser + var err error + + signer := "" + if c.sign { + signer, err = c.Signer() + if err != nil { + return errors.Wrap(err, "Signer") + } + } + + if c.encrypt { + rcpts, err := getRecipientsEmail(c) + if err != nil { + return err + } + + if c.acct.acct.PgpSelfEncrypt { + signer, err := c.Signer() + if err != nil { + return err + } + rcpts = append(rcpts, signer) + } + + cleartext, err = CryptoProvider().Encrypt(&buf, rcpts, signer, DecryptKeys, header) + if err != nil { + return err + } + } else { + cleartext, err = CryptoProvider().Sign(&buf, signer, DecryptKeys, header) + if err != nil { + return err + } + } + + err = writeMsgImpl(c, &signedHeader, cleartext) + if err != nil { + return err + } + err = cleartext.Close() + if err != nil { + return err + } + _, err = io.Copy(writer, &buf) + if err != nil { + return fmt.Errorf("failed to write message: %w", err) + } + return nil + + } else { + return writeMsgImpl(c, header, writer) + } +} + +func (c *Composer) ShouldWarnAttachment() bool { + regex := config.Compose.NoAttachmentWarning + + if regex == nil || len(c.attachments) > 0 { + return false + } + + body, err := c.GetBody() + if err != nil { + log.Warnf("failed to check for a forgotten attachment: %v", err) + return true + } + + return regex.Match(body.Bytes()) +} + +func (c *Composer) ShouldWarnSubject() bool { + if !config.Compose.EmptySubjectWarning { + return false + } + + // ignore errors because the raw header field is sufficient here + subject, _ := c.header.Subject() + return len(subject) == 0 +} + +func (c *Composer) CheckForMultipartErrors() error { + problems := []string{} + for _, p := range c.textParts { + if p.ConversionError != nil { + text := fmt.Sprintf("%s: %s", p.MimeType, p.ConversionError.Error()) + problems = append(problems, text) + } + } + + if len(problems) == 0 { + return nil + } + + return fmt.Errorf("multipart conversion error: %s", strings.Join(problems, "; ")) +} + +func writeMsgImpl(c *Composer, header *mail.Header, writer io.Writer) error { + mimeParams := map[string]string{"Charset": "UTF-8"} + if config.Compose.FormatFlowed { + mimeParams["Format"] = "Flowed" + } + body, err := c.GetBody() + if err != nil { + return err + } + if len(c.attachments) == 0 && len(c.textParts) == 0 { + // no attachments + return writeInlineBody(header, body, writer, mimeParams) + } else { + // with attachments + w, err := mail.CreateWriter(writer, *header) + if err != nil { + return errors.Wrap(err, "CreateWriter") + } + newPart, err := lib.NewPart("text/plain", mimeParams, body) + if err != nil { + return err + } + parts := []*lib.Part{newPart} + if err := writeMultipartBody(append(parts, c.textParts...), w); err != nil { + return errors.Wrap(err, "writeMultipartBody") + } + for _, a := range c.attachments { + if err := a.WriteTo(w); err != nil { + return errors.Wrap(err, "writeAttachment") + } + } + w.Close() + } + return nil +} + +func writeInlineBody( + header *mail.Header, + body io.Reader, + writer io.Writer, + mimeParams map[string]string, +) error { + header.SetContentType("text/plain", mimeParams) + w, err := mail.CreateSingleInlineWriter(writer, *header) + if err != nil { + return errors.Wrap(err, "CreateSingleInlineWriter") + } + defer w.Close() + if _, err := io.Copy(w, body); err != nil { + return errors.Wrap(err, "io.Copy") + } + return nil +} + +// write the message body to the multipart message +func writeMultipartBody(parts []*lib.Part, w *mail.Writer) error { + bi, err := w.CreateInline() + if err != nil { + return errors.Wrap(err, "CreateInline") + } + defer bi.Close() + + for _, part := range parts { + bh := mail.InlineHeader{} + bh.SetContentType(part.MimeType, part.Params) + bw, err := bi.CreatePart(bh) + if err != nil { + return errors.Wrap(err, "CreatePart") + } + defer bw.Close() + if _, err := io.Copy(bw, part.NewReader()); err != nil { + return errors.Wrap(err, "io.Copy") + } + } + + return nil +} + +func (c *Composer) GetAttachments() []string { + var names []string + for _, a := range c.attachments { + names = append(names, a.Name()) + } + return names +} + +func (c *Composer) AddAttachment(path string) { + path, _ = filepath.Abs(path) + path = xdg.TildeHome(path) + c.attachments = append(c.attachments, lib.NewFileAttachment(path)) + c.resetReview() +} + +func (c *Composer) AddPartAttachment(name string, mimetype string, + params map[string]string, body io.Reader, +) error { + p, err := lib.NewPart(mimetype, params, body) + if err != nil { + return err + } + c.attachments = append(c.attachments, lib.NewPartAttachment( + p, name, + )) + c.resetReview() + return nil +} + +func (c *Composer) DeleteAttachment(name string) error { + for i, a := range c.attachments { + if a.Name() == name { + c.attachments = append(c.attachments[:i], c.attachments[i+1:]...) + c.resetReview() + return nil + } + } + + return errors.New("attachment does not exist") +} + +func (c *Composer) resetReview() { + if c.review != nil { + c.grid.Load().(*ui.Grid).RemoveChild(c.review) + c.review = newReviewMessage(c, nil) + c.grid.Load().(*ui.Grid).AddChild(c.review).At(3, 0) + } +} + +func (c *Composer) termEvent(event vaxis.Event) bool { + if event, ok := event.(vaxis.Mouse); ok { + if event.Button == vaxis.MouseLeftButton { + c.FocusTerminal() + return true + } + } + return false +} + +func (c *Composer) reopenEmailFile() error { + name := c.email.Name() + f, err := os.OpenFile(name, os.O_RDWR, 0o600) + if err != nil { + return err + } + err = c.email.Close() + c.email = f + return err +} + +func (c *Composer) termClosed(err error) { + c.Lock() + // RemoveTab() on error must be called *AFTER* c.Unlock() but the defer + // statement does the exact opposite (last defer statement is executed + // first). Use an explicit list that begins with unlocking first. + deferred := []func(){c.Unlock} + defer func() { + for _, d := range deferred { + d() + } + }() + if c.editor == nil { + return + } + if e := c.reopenEmailFile(); e != nil { + PushError("Failed to reopen email file: " + e.Error()) + } + editor := c.editor + deferred = append(deferred, editor.Destroy) + c.editor = nil + c.focusable = c.focusable[:len(c.focusable)-1] + if c.focused >= len(c.focusable) { + c.focused = len(c.focusable) - 1 + } + + if editor.cmd.ProcessState.ExitCode() > 0 { + deferred = append(deferred, func() { + RemoveTab(c, true) + PushError("Editor exited with error. Compose aborted!") + }) + return + } + + if c.editHeaders { + // parse embedded header when editor is closed + embedHeader, err := c.parseEmbeddedHeader() + if err != nil { + PushError(err.Error()) + err := c.showTerminal() + if err != nil { + deferred = append(deferred, func() { + RemoveTab(c, true) + PushError(err.Error()) + }) + } + return + } + // delete previous headers first + for _, h := range c.headerOrder() { + c.delEditor(h) + } + hf := embedHeader.Fields() + for hf.Next() { + if hf.Value() != "" { + // add new header values in order + c.addEditor(hf.Key(), hf.Value(), false) + } + } + } + + // prepare review window + c.review = newReviewMessage(c, err) + c.updateGrid() +} + +func (c *Composer) ShowTerminal(editHeaders bool) error { + c.Lock() + defer c.Unlock() + if c.editor != nil { + return nil + } + body, err := c.GetBody() + if err != nil { + return err + } + c.editHeaders = editHeaders + err = c.setContents(body) + if err != nil { + return err + } + return c.showTerminal() +} + +func (c *Composer) showTerminal() error { + if c.editor != nil { + c.editor.Destroy() + } + editorName, err := CmdFallbackSearch(config.EditorCmds(), false) + if err != nil { + c.acct.PushError(fmt.Errorf("could not start editor: %w", err)) + } + editor := exec.Command("/bin/sh", "-c", editorName+" "+c.email.Name()) + env := os.Environ() + env = append(env, fmt.Sprintf("AERC_ACCOUNT=%s", c.Account().Name())) + env = append(env, fmt.Sprintf("AERC_ADDRESS_BOOK_CMD=%s", c.Account().AccountConfig().AddressBookCmd)) + editor.Env = env + + c.editor, err = NewTerminal(editor) + if err != nil { + return err + } + c.editor.OnEvent = c.termEvent + c.editor.OnClose = c.termClosed + c.focusable = append(c.focusable, c.editor) + c.review = nil + c.updateGrid() + if c.editHeaders || config.Compose.FocusBody { + c.focusTerminalPriv() + } + return nil +} + +func (c *Composer) PrevField() bool { + c.Lock() + defer c.Unlock() + if c.editHeaders || c.editor == nil { + return false + } + c.focusActiveWidget(false) + c.focused-- + if c.focused == -1 { + c.focused = len(c.focusable) - 1 + } + c.focusActiveWidget(true) + return true +} + +func (c *Composer) NextField() bool { + c.Lock() + defer c.Unlock() + if c.editHeaders || c.editor == nil { + return false + } + c.focusActiveWidget(false) + c.focused = (c.focused + 1) % len(c.focusable) + c.focusActiveWidget(true) + return true +} + +func (c *Composer) FocusEditor(editor string) bool { + c.Lock() + defer c.Unlock() + if c.editHeaders || c.editor == nil { + return false + } + return c.focusEditor(editor) +} + +func (c *Composer) focusEditor(editor string) bool { + editor = strings.ToLower(editor) + c.focusActiveWidget(false) + defer c.focusActiveWidget(true) + for i, f := range c.focusable { + e := f.(*headerEditor) + if strings.ToLower(e.name) == editor { + c.focused = i + return true + } + } + return false +} + +// AddEditor appends a new header editor to the compose window. +func (c *Composer) AddEditor(header string, value string, appendHeader bool) error { + c.Lock() + defer c.Unlock() + if c.editHeaders && c.editor != nil { + return errors.New("header should be added directly in the text editor") + } + value = c.addEditor(header, value, appendHeader) + if value == "" { + c.focusEditor(header) + } + c.updateGrid() + return nil +} + +func (c *Composer) addEditor(header string, value string, appendHeader bool) string { + var editor *headerEditor + header = strings.ToLower(header) + if e, ok := c.editors[header]; ok { + e.storeValue() // flush modifications from the user to the header + editor = e + } else { + uiConfig := c.acct.UiConfig() + e := newHeaderEditor(header, c.header, uiConfig) + if uiConfig.CompletionPopovers { + e.input.TabComplete( + c.completer.ForHeader(header), + uiConfig.CompletionDelay, + uiConfig.CompletionMinChars, + &config.Binds.Compose.CompleteKey, + ) + } + c.editors[header] = e + c.layout = append(c.layout, []string{header}) + if len(c.focusable) == 0 || c.editor == nil { + // no terminal editor, insert at the end + c.focusable = append(c.focusable, e) + } else { + // Insert focus of new editor before terminal editor + c.focusable = append( + c.focusable[:len(c.focusable)-1], + e, + c.focusable[len(c.focusable)-1], + ) + } + editor = e + } + + if appendHeader { + currVal := editor.input.String() + if currVal != "" { + value = strings.TrimSpace(currVal) + ", " + value + } + } + if value != "" || appendHeader { + c.editors[header].input.Set(value) + editor.storeValue() + } + return value +} + +// DelEditor removes a header editor from the compose window. +func (c *Composer) DelEditor(header string) error { + c.Lock() + defer c.Unlock() + if c.editHeaders && c.editor != nil { + return errors.New("header should be removed directly in the text editor") + } + c.delEditor(header) + c.updateGrid() + return nil +} + +func (c *Composer) delEditor(header string) { + header = strings.ToLower(header) + c.header.Del(header) + editor, ok := c.editors[header] + if !ok { + return + } + + var layout HeaderLayout = make([][]string, 0, len(c.layout)) + for _, row := range c.layout { + r := make([]string, 0, len(row)) + for _, h := range row { + if h != header { + r = append(r, h) + } + } + if len(r) > 0 { + layout = append(layout, r) + } + } + c.layout = layout + + focusable := make([]ui.MouseableDrawableInteractive, 0, len(c.focusable)-1) + for i, f := range c.focusable { + if f == editor { + if c.focused > 0 && c.focused >= i { + c.focused-- + } + } else { + focusable = append(focusable, f) + } + } + c.focusable = focusable + c.focusActiveWidget(true) + + delete(c.editors, header) +} + +// updateGrid should be called when the underlying header layout is changed. +func (c *Composer) updateGrid() { + grid := ui.NewGrid().Columns([]ui.GridSpec{ + {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)}, + }) + + if c.editHeaders && c.review == nil { + grid.Rows([]ui.GridSpec{ + // 0: editor + {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)}, + }) + if c.editor != nil { + grid.AddChild(c.editor).At(0, 0) + } + c.grid.Store(grid) + return + } + + heditors, height := c.layout.grid( + func(h string) ui.Drawable { + return c.editors[h] + }, + ) + + crHeight := 0 + if c.sign || c.encrypt { + crHeight = 1 + } + grid.Rows([]ui.GridSpec{ + // 0: headers + {Strategy: ui.SIZE_EXACT, Size: ui.Const(height)}, + // 1: crypto status + {Strategy: ui.SIZE_EXACT, Size: ui.Const(crHeight)}, + // 2: filler line + {Strategy: ui.SIZE_EXACT, Size: ui.Const(1)}, + // 3: editor or review + {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)}, + }) + + borderStyle := c.acct.UiConfig().GetStyle(config.STYLE_BORDER) + borderChar := c.acct.UiConfig().BorderCharHorizontal + grid.AddChild(heditors).At(0, 0) + grid.AddChild(c.crypto).At(1, 0) + grid.AddChild(ui.NewFill(borderChar, borderStyle)).At(2, 0) + if c.review != nil { + grid.AddChild(c.review).At(3, 0) + } else if c.editor != nil { + grid.AddChild(c.editor).At(3, 0) + } + c.heditors.Store(heditors) + c.grid.Store(grid) +} + +type headerEditor struct { + name string + header *mail.Header + focused bool + input *ui.TextInput + uiConfig *config.UIConfig +} + +func newHeaderEditor(name string, h *mail.Header, + uiConfig *config.UIConfig, +) *headerEditor { + he := &headerEditor{ + input: ui.NewTextInput("", uiConfig), + name: name, + header: h, + uiConfig: uiConfig, + } + he.loadValue() + return he +} + +// extractHumanHeaderValue extracts the human readable string for key from the +// header. If a parsing error occurs the raw value is returned +func extractHumanHeaderValue(key string, h *mail.Header) string { + var val string + var err error + switch strings.ToLower(key) { + case "to", "from", "cc", "bcc": + var list []*mail.Address + list, err = h.AddressList(key) + val = format.FormatAddresses(list) + default: + val, err = h.Text(key) + } + if err != nil { + // if we can't parse it, show it raw + val = h.Get(key) + } + return val +} + +// loadValue loads the value of he.name form the underlying header +// the value is decoded and meant for human consumption. +// decoding issues are ignored and return their raw values +func (he *headerEditor) loadValue() { + he.input.Set(extractHumanHeaderValue(he.name, he.header)) + ui.Invalidate() +} + +// storeValue writes the current state back to the underlying header. +// errors are ignored +func (he *headerEditor) storeValue() { + val := he.input.String() + switch strings.ToLower(he.name) { + case "to", "from", "cc", "bcc": + if strings.TrimSpace(val) == "" { + // if header is empty, delete it + he.header.Del(he.name) + return + } + list, err := mail.ParseAddressList(val) + if err == nil { + he.header.SetAddressList(he.name, list) + } else { + // garbage, but it'll blow up upon sending and the user can + // fix the issue + he.header.SetText(he.name, val) + } + default: + he.header.SetText(he.name, val) + } + if strings.ToLower(he.name) == "from" { + he.header.Del("message-id") + } +} + +func (he *headerEditor) Draw(ctx *ui.Context) { + name := textproto.CanonicalMIMEHeaderKey(he.name) + // Extra character to put a blank cell between the header and the input + size := runewidth.StringWidth(name+":") + 1 + defaultStyle := he.uiConfig.GetStyle(config.STYLE_DEFAULT) + headerStyle := he.uiConfig.GetStyle(config.STYLE_HEADER) + ctx.Fill(0, 0, size, ctx.Height(), ' ', defaultStyle) + ctx.Printf(0, 0, headerStyle, "%s:", name) + he.input.Draw(ctx.Subcontext(size, 0, ctx.Width()-size, 1)) +} + +func (he *headerEditor) MouseEvent(localX int, localY int, event vaxis.Event) { + if event, ok := event.(vaxis.Mouse); ok { + if event.Button == vaxis.MouseLeftButton { + he.focused = true + } + + width := runewidth.StringWidth(he.name + " ") + if localX >= width { + he.input.MouseEvent(localX-width, localY, event) + } + } +} + +func (he *headerEditor) Invalidate() { + ui.Invalidate() +} + +func (he *headerEditor) Focus(focused bool) { + he.focused = focused + he.input.Focus(focused) +} + +func (he *headerEditor) Event(event vaxis.Event) bool { + return he.input.Event(event) +} + +func (he *headerEditor) OnChange(fn func()) { + he.input.OnChange(func(_ *ui.TextInput) { + fn() + }) +} + +func (he *headerEditor) OnFocusLost(fn func()) { + he.input.OnFocusLost(func(_ *ui.TextInput) { + fn() + }) +} + +type reviewMessage struct { + composer *Composer + grid *ui.Grid +} + +var defaultAnnotations = map[string]string{ + ":send<enter>": "Send", + ":edit<enter>": "Edit (body and headers)", + ":attach<space>": "Add attachment", + ":detach<space>": "Remove attachment", + ":postpone<enter>": "Postpone", + ":preview<enter>": "Preview message", + ":abort<enter>": "Abort (discard message, no confirmation)", + ":choose -o d discard abort -o p postpone postpone<enter>": "Abort or postpone", +} + +func newReviewMessage(composer *Composer, err error) *reviewMessage { + bindings := config.Binds.ComposeReview.ForAccount( + composer.acctConfig.Name, + ) + bindings = bindings.ForFolder(composer.SelectedDirectory()) + + type reviewCmd struct { + input string + output string + annotation string + } + + var reviewCmds []reviewCmd + + for _, binding := range bindings.Bindings { + if binding.Annotation == "-" { + // explicitly hidden by user + continue + } + + inputs := config.FormatKeyStrokes(binding.Input) + outputs := config.FormatKeyStrokes(binding.Output) + annotation := binding.Annotation + if annotation == "" { + for i := range reviewCmds { + r := &reviewCmds[i] + if r.output == outputs { + // aliased action with a different binding + r.input += ", " + inputs + goto next + } + } + annotation = defaultAnnotations[outputs] + } + reviewCmds = append(reviewCmds, reviewCmd{ + input: inputs, + output: outputs, + annotation: annotation, + }) + next: + } + + longest := 0 + for _, rcmd := range reviewCmds { + if len(rcmd.input) > longest { + longest = len(rcmd.input) + } + } + + const maxInputWidth = 6 + width := longest + if longest < maxInputWidth { + width = maxInputWidth + } + widthstr := strconv.Itoa(width) + + var actions []string + for _, rcmd := range reviewCmds { + actions = append(actions, fmt.Sprintf(" %-"+widthstr+"s %-40s %s", + rcmd.input, rcmd.annotation, rcmd.output)) + } + + spec := []ui.GridSpec{ + {Strategy: ui.SIZE_EXACT, Size: ui.Const(1)}, + } + for i := 0; i < len(actions)-1; i++ { + spec = append(spec, ui.GridSpec{Strategy: ui.SIZE_EXACT, Size: ui.Const(1)}) + } + spec = append(spec, ui.GridSpec{Strategy: ui.SIZE_EXACT, Size: ui.Const(2)}) + spec = append(spec, ui.GridSpec{Strategy: ui.SIZE_EXACT, Size: ui.Const(1)}) + for i := 0; i < len(composer.attachments)-1; i++ { + spec = append(spec, ui.GridSpec{Strategy: ui.SIZE_EXACT, Size: ui.Const(1)}) + } + if len(composer.textParts) > 0 { + spec = append(spec, ui.GridSpec{Strategy: ui.SIZE_EXACT, Size: ui.Const(1)}) + spec = append(spec, ui.GridSpec{Strategy: ui.SIZE_EXACT, Size: ui.Const(1)}) + for i := 0; i < len(composer.textParts); i++ { + spec = append(spec, ui.GridSpec{Strategy: ui.SIZE_EXACT, Size: ui.Const(1)}) + } + } + // make the last element fill remaining space + spec = append(spec, ui.GridSpec{Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)}) + + grid := ui.NewGrid().Rows(spec).Columns([]ui.GridSpec{ + {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)}, + }) + + uiConfig := composer.acct.UiConfig() + + if err != nil { + grid.AddChild(ui.NewText(err.Error(), uiConfig.GetStyle(config.STYLE_ERROR))) + grid.AddChild(ui.NewText("Press [q] to close this tab.", + uiConfig.GetStyle(config.STYLE_DEFAULT))).At(1, 0) + } else { + grid.AddChild(ui.NewText("Send this email?", + uiConfig.GetStyle(config.STYLE_TITLE))).At(0, 0) + i := 1 + for _, action := range actions { + grid.AddChild(ui.NewText(action, + uiConfig.GetStyle(config.STYLE_DEFAULT))).At(i, 0) + i += 1 + } + grid.AddChild(ui.NewText("Attachments:", + uiConfig.GetStyle(config.STYLE_TITLE))).At(i, 0) + i += 1 + if len(composer.attachments) == 0 { + grid.AddChild(ui.NewText("(none)", + uiConfig.GetStyle(config.STYLE_DEFAULT))).At(i, 0) + i += 1 + } else { + for _, a := range composer.attachments { + grid.AddChild(ui.NewText(a.Name(), uiConfig.GetStyle(config.STYLE_DEFAULT))). + At(i, 0) + i += 1 + } + } + if len(composer.textParts) > 0 { + grid.AddChild(ui.NewText("Parts:", + uiConfig.GetStyle(config.STYLE_TITLE))).At(i, 0) + i += 1 + grid.AddChild(ui.NewText("text/plain", uiConfig.GetStyle(config.STYLE_DEFAULT))).At(i, 0) + i += 1 + for _, p := range composer.textParts { + err := composer.updateMultipart(p) + if err != nil { + msg := fmt.Sprintf("%s error: %s", p.MimeType, err) + grid.AddChild(ui.NewText(msg, + uiConfig.GetStyle(config.STYLE_ERROR))).At(i, 0) + } else { + grid.AddChild(ui.NewText(p.MimeType, + uiConfig.GetStyle(config.STYLE_DEFAULT))).At(i, 0) + } + i += 1 + } + + } + } + + return &reviewMessage{ + composer: composer, + grid: grid, + } +} + +func (c *Composer) updateMultipart(p *lib.Part) error { + // conversion errors handling + p.ConversionError = nil + setError := func(e error) error { + p.ConversionError = e + return e + } + if !p.Converted { + // text/* multipart created without a command (e.g. by :accept) + return nil + } + command, found := config.Converters[p.MimeType] + if !found { + // unreachable + return setError(fmt.Errorf("no command defined for mime/type")) + } + // reset part body to avoid it leaving outdated if the command fails + p.Data = nil + body, err := c.GetBody() + if err != nil { + return setError(errors.Wrap(err, "GetBody")) + } + cmd := exec.Command("sh", "-c", command) + cmd.Stdin = body + out, err := cmd.Output() + if err != nil { + var stderr string + var ee *exec.ExitError + if errors.As(err, &ee) { + // append the first 30 chars of stderr if any + stderr = strings.Trim(string(ee.Stderr), " \t\n\r") + stderr = strings.ReplaceAll(stderr, "\n", "; ") + if stderr != "" { + stderr = fmt.Sprintf(": %.30s", stderr) + } + } + return setError(fmt.Errorf("%s: %w%s", command, err, stderr)) + } + p.Data = out + return nil +} + +func (rm *reviewMessage) Invalidate() { + ui.Invalidate() +} + +func (rm *reviewMessage) Draw(ctx *ui.Context) { + rm.grid.Draw(ctx) +} + +type cryptoStatus struct { + title string + status *ui.Text + uiConfig *config.UIConfig + signKey string + setEncOneShot bool +} + +func newCryptoStatus(uiConfig *config.UIConfig) *cryptoStatus { + defaultStyle := uiConfig.GetStyle(config.STYLE_DEFAULT) + return &cryptoStatus{ + title: "Security", + status: ui.NewText("", defaultStyle), + uiConfig: uiConfig, + signKey: "", + setEncOneShot: true, + } +} + +func (cs *cryptoStatus) Draw(ctx *ui.Context) { + // Extra character to put a blank cell between the header and the input + size := runewidth.StringWidth(cs.title+":") + 1 + defaultStyle := cs.uiConfig.GetStyle(config.STYLE_DEFAULT) + titleStyle := cs.uiConfig.GetStyle(config.STYLE_HEADER) + ctx.Fill(0, 0, size, ctx.Height(), ' ', defaultStyle) + ctx.Printf(0, 0, titleStyle, "%s:", cs.title) + cs.status.Draw(ctx.Subcontext(size, 0, ctx.Width()-size, 1)) +} + +func (cs *cryptoStatus) Invalidate() { + ui.Invalidate() +} + +func (c *Composer) checkEncryptionKeys(_ string) bool { + rcpts, err := getRecipientsEmail(c) + if err != nil { + // checkEncryptionKeys gets registered as a callback and must + // explicitly call c.SetEncrypt(false) when encryption is not possible + c.SetEncrypt(false) + st := fmt.Sprintf("Cannot encrypt: %v", err) + aerc.statusline.PushError(st) + return false + } + var mk []string + for _, rcpt := range rcpts { + key, err := CryptoProvider().GetKeyId(rcpt) + if err != nil || key == "" { + mk = append(mk, rcpt) + } + } + + encrypt := true + switch { + case len(mk) > 0: + c.SetEncrypt(false) + st := fmt.Sprintf("Cannot encrypt, missing keys: %s", strings.Join(mk, ", ")) + if c.Config().PgpOpportunisticEncrypt { + switch c.Config().PgpErrorLevel { + case config.PgpErrorLevelWarn: + aerc.statusline.PushWarning(st) + return false + case config.PgpErrorLevelNone: + return false + case config.PgpErrorLevelError: + // Continue to the default + } + } + PushError(st) + encrypt = false + case len(rcpts) == 0: + encrypt = false + } + + // If callbacks were registered, encrypt will be set when user removes + // recipients with missing keys + c.encrypt = encrypt + err = c.updateCrypto() + if err != nil { + log.Warnf("failed update crypto: %v", err) + } + return true +} + +// setTitle executes the title template and sets the tab title +func (c *Composer) setTitle() { + if c.Tab == nil { + return + } + + header := c.header.Copy() + // Get subject direct from the textinput + subject, ok := c.editors["subject"] + if ok { + header.SetSubject(subject.input.String()) + } + if header.Get("subject") == "" { + header.SetSubject("New Email") + } + + data := state.NewDataSetter() + data.SetAccount(c.acctConfig) + data.SetFolder(c.acct.Directories().SelectedDirectory()) + data.SetHeaders(&header, c.parent) + + var buf bytes.Buffer + uiConf := c.acct.UiConfig() + err := templates.Render(uiConf.TabTitleComposer, &buf, + data.Data()) + if err != nil { + c.acct.PushError(err) + return + } + c.Tab.SetTitle(buf.String()) +} diff --git a/app/dialog.go b/app/dialog.go new file mode 100644 index 0000000..64dbf12 --- /dev/null +++ b/app/dialog.go @@ -0,0 +1,70 @@ +package app + +import ( + "git.sr.ht/~rjarry/aerc/lib/ui" +) + +type Dialog interface { + ui.DrawableInteractive + ContextWidth() (func(int) int, func(int) int) + ContextHeight() (func(int) int, func(int) int) +} + +type dialog struct { + ui.DrawableInteractive + x func(int) int + y func(int) int + w func(int) int + h func(int) int +} + +func (d *dialog) ContextWidth() (func(int) int, func(int) int) { + return d.x, d.w +} + +func (d *dialog) ContextHeight() (func(int) int, func(int) int) { + return d.y, d.h +} + +func NewDialog( + d ui.DrawableInteractive, + x func(int) int, y func(int) int, + w func(int) int, h func(int) int, +) *dialog { + return &dialog{DrawableInteractive: d, x: x, y: y, w: w, h: h} +} + +// DefaultDialog creates a dialog window spanning half of the screen +func DefaultDialog(d ui.DrawableInteractive) Dialog { + position := SelectedAccountUiConfig().DialogPosition + width := SelectedAccountUiConfig().DialogWidth + height := SelectedAccountUiConfig().DialogHeight + return NewDialog(d, + // horizontal starting position in columns from the left + func(w int) int { + return (w * (100 - width)) / 200 + }, + // vertical starting position in lines from the top + func(h int) int { + switch position { + case "center": + return (h * (100 - height)) / 200 + case "bottom": + return h - (h * height / 100) + default: + return 1 + } + }, + // dialog width from the starting column + func(w int) int { + return w * width / 100 + }, + // dialog height from the starting line + func(h int) int { + if position == "bottom" { + return h*height/100 - 1 + } + return h * height / 100 + }, + ) +} diff --git a/app/dirlist.go b/app/dirlist.go new file mode 100644 index 0000000..9969a99 --- /dev/null +++ b/app/dirlist.go @@ -0,0 +1,559 @@ +package app + +import ( + "bytes" + "context" + "math" + "regexp" + "sort" + "time" + + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/lib" + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/state" + "git.sr.ht/~rjarry/aerc/lib/templates" + "git.sr.ht/~rjarry/aerc/lib/ui" + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/types" + "git.sr.ht/~rockorager/vaxis" +) + +type DirectoryLister interface { + ui.Drawable + + Selected() string + Previous() string + + Select(string) + Open(string, string, time.Duration, func(types.WorkerMessage), bool) + + Update(types.WorkerMessage) + List() []string + ClearList() + + OnVirtualNode(func()) + + NextPrev(int) + + CollapseFolder(string) + ExpandFolder(string) + + SelectedMsgStore() (*lib.MessageStore, bool) + MsgStore(string) (*lib.MessageStore, bool) + SelectedDirectory() *models.Directory + Directory(string) *models.Directory + SetMsgStore(*models.Directory, *lib.MessageStore) + + FilterDirs([]string, []string, bool) []string + GetRUECount(string) (int, int, int) + + UiConfig(string) *config.UIConfig +} + +type DirectoryList struct { + Scrollable + acctConf *config.AccountConfig + store *lib.DirStore + dirs []string + selecting string + selected string + previous string + spinner *Spinner + worker *types.Worker + ctx context.Context + cancel context.CancelFunc +} + +func NewDirectoryList(acctConf *config.AccountConfig, + worker *types.Worker, +) DirectoryLister { + dirlist := &DirectoryList{ + acctConf: acctConf, + store: lib.NewDirStore(), + worker: worker, + } + dirlist.NewContext() + uiConf := dirlist.UiConfig("") + dirlist.spinner = NewSpinner(uiConf) + dirlist.spinner.Start() + + if uiConf.DirListTree { + return NewDirectoryTree(dirlist) + } + + return dirlist +} + +func (dirlist *DirectoryList) NewContext() { + if dirlist.cancel != nil { + dirlist.cancel() + } + dirlist.ctx, dirlist.cancel = context.WithCancel(context.Background()) +} + +func (dirlist *DirectoryList) UiConfig(dir string) *config.UIConfig { + if dir == "" { + dir = dirlist.Selected() + } + return config.Ui.ForAccount(dirlist.acctConf.Name).ForFolder(dir) +} + +func (dirlist *DirectoryList) List() []string { + return dirlist.dirs +} + +func (dirlist *DirectoryList) ClearList() { + dirlist.dirs = []string{} +} + +func (dirlist *DirectoryList) OnVirtualNode(_ func()) { +} + +func (dirlist *DirectoryList) Update(msg types.WorkerMessage) { + switch msg := msg.(type) { + case *types.Done: + switch msg := msg.InResponseTo().(type) { + case *types.OpenDirectory: + dirlist.previous = dirlist.selected + dirlist.selected = msg.Directory + dirlist.filterDirsByFoldersConfig() + hasSelected := false + for _, d := range dirlist.dirs { + if d == dirlist.selected { + hasSelected = true + break + } + } + if !hasSelected && dirlist.selected != "" { + dirlist.dirs = append(dirlist.dirs, dirlist.selected) + } + if dirlist.acctConf.EnableFoldersSort { + sort.Strings(dirlist.dirs) + } + dirlist.sortDirsByFoldersSortConfig() + store, ok := dirlist.SelectedMsgStore() + if !ok { + return + } + store.SetContext(msg.Context) + case *types.ListDirectories: + dirlist.filterDirsByFoldersConfig() + dirlist.sortDirsByFoldersSortConfig() + dirlist.spinner.Stop() + dirlist.Invalidate() + case *types.RemoveDirectory: + dirlist.store.Remove(msg.Directory) + dirlist.filterDirsByFoldersConfig() + dirlist.sortDirsByFoldersSortConfig() + case *types.CreateDirectory: + dirlist.filterDirsByFoldersConfig() + dirlist.sortDirsByFoldersSortConfig() + dirlist.Invalidate() + } + case *types.DirectoryInfo: + dir := dirlist.Directory(msg.Info.Name) + if dir == nil { + return + } + dir.Exists = msg.Info.Exists + dir.Recent = msg.Info.Recent + dir.Unseen = msg.Info.Unseen + if msg.Refetch { + store, ok := dirlist.SelectedMsgStore() + if ok { + store.Sort(store.GetCurrentSortCriteria(), nil) + } + } + default: + return + } +} + +func (dirlist *DirectoryList) CollapseFolder(string) { + // no effect for the DirectoryList +} + +func (dirlist *DirectoryList) ExpandFolder(string) { + // no effect for the DirectoryList +} + +func (dirlist *DirectoryList) Select(name string) { + dirlist.Open(name, "", dirlist.UiConfig(name).DirListDelay, nil, false) +} + +func (dirlist *DirectoryList) Open(name string, query string, delay time.Duration, + cb func(types.WorkerMessage), force bool, +) { + dirlist.selecting = name + + dirlist.NewContext() + + go func(ctx context.Context) { + defer log.PanicHandler() + + select { + case <-time.After(delay): + dirlist.worker.PostAction(&types.OpenDirectory{ + Context: ctx, + Directory: name, + Query: query, + Force: force, + }, + func(msg types.WorkerMessage) { + switch msg := msg.(type) { + case *types.Error: + dirlist.selecting = "" + log.Errorf("(%s) couldn't open directory %s: %v", + dirlist.acctConf.Name, + name, + msg.Error) + case *types.Cancelled: + log.Debugf("OpenDirectory cancelled") + } + if cb != nil { + cb(msg) + } + }) + case <-ctx.Done(): + log.Tracef("dirlist: skip %s", name) + return + } + }(dirlist.ctx) +} + +func (dirlist *DirectoryList) Selected() string { + return dirlist.selected +} + +func (dirlist *DirectoryList) Previous() string { + return dirlist.previous +} + +func (dirlist *DirectoryList) Invalidate() { + ui.Invalidate() +} + +// Returns the Recent, Unread, and Exist counts for the named directory +func (dirlist *DirectoryList) GetRUECount(name string) (int, int, int) { + dir := dirlist.Directory(name) + if dir == nil { + return 0, 0, 0 + } + return dir.Recent, dir.Unseen, dir.Exists +} + +func (dirlist *DirectoryList) Draw(ctx *ui.Context) { + uiConfig := dirlist.UiConfig("") + ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', + uiConfig.GetStyle(config.STYLE_DIRLIST_DEFAULT)) + + if dirlist.spinner.IsRunning() { + dirlist.spinner.Draw(ctx) + return + } + + if len(dirlist.dirs) == 0 { + style := uiConfig.GetStyle(config.STYLE_DIRLIST_DEFAULT) + ctx.Printf(0, 0, style, "%s", uiConfig.EmptyDirlist) + return + } + + dirlist.UpdateScroller(ctx.Height(), len(dirlist.dirs)) + dirlist.EnsureScroll(findString(dirlist.dirs, dirlist.selecting)) + + textWidth := ctx.Width() + if dirlist.NeedScrollbar() { + textWidth -= 1 + } + if textWidth < 0 { + return + } + + listCtx := ctx.Subcontext(0, 0, textWidth, ctx.Height()) + + data := state.NewDataSetter() + data.SetAccount(dirlist.acctConf) + + for i, name := range dirlist.dirs { + if i < dirlist.Scroll() { + continue + } + row := i - dirlist.Scroll() + if row >= ctx.Height() { + break + } + + data.SetFolder(dirlist.Directory(name)) + data.SetRUE([]string{name}, dirlist.GetRUECount) + left, right, style := dirlist.renderDir( + name, uiConfig, data.Data(), + name == dirlist.selecting, listCtx.Width(), + ) + listCtx.Printf(0, row, style, "%s %s", left, right) + } + + if dirlist.NeedScrollbar() { + scrollBarCtx := ctx.Subcontext(ctx.Width()-1, 0, 1, ctx.Height()) + dirlist.drawScrollbar(scrollBarCtx) + } +} + +func (dirlist *DirectoryList) renderDir( + path string, conf *config.UIConfig, data models.TemplateData, + selected bool, width int, +) (string, string, vaxis.Style) { + var left, right string + var buf bytes.Buffer + + var styles []config.StyleObject + var style vaxis.Style + + r, u, _ := dirlist.GetRUECount(path) + if u > 0 { + styles = append(styles, config.STYLE_DIRLIST_UNREAD) + } + if r > 0 { + styles = append(styles, config.STYLE_DIRLIST_RECENT) + } + conf = conf.ForFolder(path) + if selected { + style = conf.GetComposedStyleSelected( + config.STYLE_DIRLIST_DEFAULT, styles) + } else { + style = conf.GetComposedStyle( + config.STYLE_DIRLIST_DEFAULT, styles) + } + + err := templates.Render(conf.DirListLeft, &buf, data) + if err != nil { + log.Errorf("dirlist-left: %s", err) + left = err.Error() + style = conf.GetStyle(config.STYLE_ERROR) + } else { + left = buf.String() + } + buf.Reset() + err = templates.Render(conf.DirListRight, &buf, data) + if err != nil { + log.Errorf("dirlist-right: %s", err) + right = err.Error() + style = conf.GetStyle(config.STYLE_ERROR) + } else { + right = buf.String() + } + buf.Reset() + + lbuf := ui.StyledString(left) + ui.ApplyAttrs(lbuf, style) + lwidth := lbuf.Len() + rbuf := ui.StyledString(right) + ui.ApplyAttrs(rbuf, style) + rwidth := rbuf.Len() + + if lwidth+rwidth+1 > width { + if rwidth > 3*width/4 { + rwidth = 3 * width / 4 + } + lwidth = width - rwidth - 1 + ui.TruncateHead(rbuf, rwidth) + right = rbuf.Encode() + ui.Truncate(lbuf, lwidth) + left = lbuf.Encode() + } else { + for i := 0; i < (width - lwidth - rwidth - 1); i += 1 { + lbuf.Cells = append(lbuf.Cells, vaxis.Cell{ + Character: vaxis.Character{ + Grapheme: " ", + Width: 1, + }, + }) + } + left = lbuf.Encode() + right = rbuf.Encode() + } + + return left, right, style +} + +func (dirlist *DirectoryList) drawScrollbar(ctx *ui.Context) { + gutterStyle := vaxis.Style{} + pillStyle := vaxis.Style{Attribute: vaxis.AttrReverse} + + // gutter + ctx.Fill(0, 0, 1, ctx.Height(), ' ', gutterStyle) + + // pill + pillSize := int(math.Ceil(float64(ctx.Height()) * dirlist.PercentVisible())) + pillOffset := int(math.Floor(float64(ctx.Height()) * dirlist.PercentScrolled())) + ctx.Fill(0, pillOffset, 1, pillSize, ' ', pillStyle) +} + +func (dirlist *DirectoryList) MouseEvent(localX int, localY int, event vaxis.Event) { + if event, ok := event.(vaxis.Mouse); ok { + switch event.Button { + case vaxis.MouseLeftButton: + clickedDir, ok := dirlist.Clicked(localX, localY) + if ok { + dirlist.Select(clickedDir) + } + case vaxis.MouseWheelDown: + dirlist.Next() + case vaxis.MouseWheelUp: + dirlist.Prev() + } + } +} + +func (dirlist *DirectoryList) Clicked(x int, y int) (string, bool) { + if len(dirlist.dirs) == 0 { + return "", false + } + for i, name := range dirlist.dirs { + if i == y { + return name, true + } + } + return "", false +} + +func (dirlist *DirectoryList) NextPrev(delta int) { + curIdx := findString(dirlist.dirs, dirlist.selecting) + if curIdx == len(dirlist.dirs) { + return + } + newIdx := curIdx + delta + ndirs := len(dirlist.dirs) + + if ndirs == 0 { + return + } + + if newIdx < 0 { + newIdx = ndirs - 1 + } else if newIdx >= ndirs { + newIdx = 0 + } + + dirlist.Select(dirlist.dirs[newIdx]) +} + +func (dirlist *DirectoryList) Next() { + dirlist.NextPrev(1) +} + +func (dirlist *DirectoryList) Prev() { + dirlist.NextPrev(-1) +} + +func folderMatches(folder string, pattern string) bool { + if len(pattern) == 0 { + return false + } + if pattern[0] == '~' { + r, err := regexp.Compile(pattern[1:]) + if err != nil { + return false + } + return r.Match([]byte(folder)) + } + return pattern == folder +} + +// sortDirsByFoldersSortConfig sets dirlist.dirs to be sorted based on the +// AccountConfig.FoldersSort option. Folders not included in the option +// will be appended at the end in alphabetical order +func (dirlist *DirectoryList) sortDirsByFoldersSortConfig() { + if !dirlist.acctConf.EnableFoldersSort { + return + } + + sort.Slice(dirlist.dirs, func(i, j int) bool { + foldersSort := dirlist.acctConf.FoldersSort + iInFoldersSort := findString(foldersSort, dirlist.dirs[i]) + jInFoldersSort := findString(foldersSort, dirlist.dirs[j]) + if iInFoldersSort >= 0 && jInFoldersSort >= 0 { + return iInFoldersSort < jInFoldersSort + } + if iInFoldersSort >= 0 { + return true + } + if jInFoldersSort >= 0 { + return false + } + return dirlist.dirs[i] < dirlist.dirs[j] + }) +} + +// filterDirsByFoldersConfig sets dirlist.dirs to the filtered subset of the +// dirstore, based on AccountConfig.Folders (inclusion) and +// AccountConfig.FoldersExclude (exclusion), in that order. +func (dirlist *DirectoryList) filterDirsByFoldersConfig() { + dirlist.dirs = dirlist.store.List() + + // 'folders' (if available) is used to make the initial list and + // 'folders-exclude' removes from that list. + configFolders := dirlist.acctConf.Folders + dirlist.dirs = dirlist.FilterDirs(dirlist.dirs, configFolders, false) + + configFoldersExclude := dirlist.acctConf.FoldersExclude + dirlist.dirs = dirlist.FilterDirs(dirlist.dirs, configFoldersExclude, true) +} + +// FilterDirs filters directories by the supplied filter. If exclude is false, +// the filter will only include directories from orig which exist in filters. +// If exclude is true, the directories in filters are removed from orig +func (dirlist *DirectoryList) FilterDirs(orig, filters []string, exclude bool) []string { + if len(filters) == 0 { + return orig + } + var dest []string + for _, folder := range orig { + // When excluding, include things by default, and vice-versa + include := exclude + for _, f := range filters { + if folderMatches(folder, f) { + // If matched an exclusion, don't include + // If matched an inclusion, do include + include = !exclude + break + } + } + if include { + dest = append(dest, folder) + } + } + return dest +} + +func (dirlist *DirectoryList) SelectedMsgStore() (*lib.MessageStore, bool) { + return dirlist.store.MessageStore(dirlist.selected) +} + +func (dirlist *DirectoryList) MsgStore(name string) (*lib.MessageStore, bool) { + return dirlist.store.MessageStore(name) +} + +func (dirlist *DirectoryList) SelectedDirectory() *models.Directory { + return dirlist.store.Directory(dirlist.selected) +} + +func (dirlist *DirectoryList) Directory(name string) *models.Directory { + return dirlist.store.Directory(name) +} + +func (dirlist *DirectoryList) SetMsgStore(dir *models.Directory, msgStore *lib.MessageStore) { + dirlist.store.SetMessageStore(dir, msgStore) + msgStore.OnUpdateDirs(func() { + dirlist.Invalidate() + }) +} + +func findString(slice []string, str string) int { + for i, s := range slice { + if str == s { + return i + } + } + return -1 +} diff --git a/app/dirtree.go b/app/dirtree.go new file mode 100644 index 0000000..30ced9a --- /dev/null +++ b/app/dirtree.go @@ -0,0 +1,543 @@ +package app + +import ( + "fmt" + "sort" + "strings" + "time" + + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/lib" + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/state" + "git.sr.ht/~rjarry/aerc/lib/ui" + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/types" + "git.sr.ht/~rockorager/vaxis" +) + +type DirectoryTree struct { + *DirectoryList + + listIdx int + list []*types.Thread + + virtual bool + virtualCb func() +} + +func NewDirectoryTree(dirlist *DirectoryList) DirectoryLister { + dt := &DirectoryTree{ + DirectoryList: dirlist, + listIdx: -1, + virtualCb: func() {}, + } + return dt +} + +func (dt *DirectoryTree) OnVirtualNode(cb func()) { + dt.virtualCb = cb +} + +func (dt *DirectoryTree) Selected() string { + if dt.listIdx < 0 || dt.listIdx >= len(dt.list) { + return dt.DirectoryList.Selected() + } + node := dt.list[dt.listIdx] + elems := dt.nodeElems(node) + n := countLevels(node) + if n < 0 || n >= len(elems) { + return "" + } + return strings.Join(elems[:(n+1)], dt.DirectoryList.worker.PathSeparator()) +} + +func (dt *DirectoryTree) SelectedDirectory() *models.Directory { + if dt.virtual { + return &models.Directory{ + Name: dt.Selected(), + Role: models.VirtualRole, + } + } + return dt.DirectoryList.SelectedDirectory() +} + +func (dt *DirectoryTree) ClearList() { + dt.list = make([]*types.Thread, 0) +} + +func (dt *DirectoryTree) Update(msg types.WorkerMessage) { + selected := dt.Selected() + switch msg := msg.(type) { + case *types.Done: + switch msg.InResponseTo().(type) { + case *types.RemoveDirectory, *types.ListDirectories, *types.CreateDirectory: + dt.DirectoryList.Update(msg) + dt.buildTree() + if selected != "" { + dt.reindex(selected) + } + dt.Invalidate() + default: + dt.DirectoryList.Update(msg) + } + default: + dt.DirectoryList.Update(msg) + } +} + +func (dt *DirectoryTree) Draw(ctx *ui.Context) { + uiConfig := dt.UiConfig("") + ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', + uiConfig.GetStyle(config.STYLE_DIRLIST_DEFAULT)) + + if dt.DirectoryList.spinner.IsRunning() { + dt.DirectoryList.spinner.Draw(ctx) + return + } + + n := dt.countVisible(dt.list) + if n == 0 || dt.listIdx < 0 { + style := uiConfig.GetStyle(config.STYLE_DIRLIST_DEFAULT) + ctx.Printf(0, 0, style, "%s", uiConfig.EmptyDirlist) + return + } + + dt.UpdateScroller(ctx.Height(), n) + dt.EnsureScroll(dt.countVisible(dt.list[:dt.listIdx])) + + needScrollbar := true + percentVisible := float64(ctx.Height()) / float64(n) + if percentVisible >= 1.0 { + needScrollbar = false + } + + textWidth := ctx.Width() + if needScrollbar { + textWidth -= 1 + } + if textWidth < 0 { + return + } + + treeCtx := ctx.Subcontext(0, 0, textWidth, ctx.Height()) + + data := state.NewDataSetter() + data.SetAccount(dt.acctConf) + + n = 0 + for i, node := range dt.list { + if n > treeCtx.Height() { + break + } + rowNr := dt.countVisible(dt.list[:i]) + if rowNr < dt.Scroll() || !isVisible(node) { + continue + } + + path := dt.getDirectory(node) + dir := dt.Directory(path) + treeDir := &models.Directory{ + Name: dt.displayText(node), + } + if dir != nil { + treeDir.Role = dir.Role + } + data.SetFolder(treeDir) + data.SetRUE([]string{path}, dt.GetRUECount) + + left, right, style := dt.renderDir( + path, uiConfig, data.Data(), + i == dt.listIdx, treeCtx.Width(), + ) + + treeCtx.Printf(0, n, style, "%s %s", left, right) + n++ + } + + if dt.NeedScrollbar() { + scrollBarCtx := ctx.Subcontext(ctx.Width()-1, 0, 1, ctx.Height()) + dt.drawScrollbar(scrollBarCtx) + } +} + +func (dt *DirectoryTree) MouseEvent(localX int, localY int, event vaxis.Event) { + if event, ok := event.(vaxis.Mouse); ok { + switch event.Button { + case vaxis.MouseLeftButton: + clickedDir, ok := dt.Clicked(localX, localY) + if ok { + dt.Select(clickedDir) + } + case vaxis.MouseWheelDown: + dt.NextPrev(1) + case vaxis.MouseWheelUp: + dt.NextPrev(-1) + } + } +} + +func (dt *DirectoryTree) Clicked(x int, y int) (string, bool) { + if len(dt.list) == 0 || dt.countVisible(dt.list) < y+dt.Scroll() { + return "", false + } + visible := 0 + for _, node := range dt.list { + if isVisible(node) { + visible++ + } + if visible == y+dt.Scroll()+1 { + if path := dt.getDirectory(node); path != "" { + return path, true + } + if node.Hidden == 0 { + node.Hidden = 1 + } else { + node.Hidden = 0 + } + dt.Invalidate() + return "", false + } + } + return "", false +} + +func (dt *DirectoryTree) SelectedMsgStore() (*lib.MessageStore, bool) { + if dt.virtual { + return nil, false + } + + selected := models.UID(dt.selected) + if _, node := dt.getTreeNode(selected); node == nil { + dt.buildTree() + selIdx, node := dt.getTreeNode(selected) + if node != nil { + makeVisible(node) + dt.listIdx = selIdx + } + } + return dt.DirectoryList.SelectedMsgStore() +} + +func (dt *DirectoryTree) reindex(name string) { + selIdx, node := dt.getTreeNode(models.UID(name)) + if node != nil { + makeVisible(node) + dt.listIdx = selIdx + } +} + +func (dt *DirectoryTree) Select(name string) { + if name == "" { + return + } + dt.Open(name, "", dt.UiConfig(name).DirListDelay, nil, false) +} + +func (dt *DirectoryTree) Open(name string, query string, delay time.Duration, cb func(types.WorkerMessage), force bool) { + if name == "" { + return + } + again := false + uid := models.UID(name) + if _, node := dt.getTreeNode(uid); node == nil { + again = true + } else { + dt.reindex(name) + } + dt.DirectoryList.Open(name, query, delay, func(msg types.WorkerMessage) { + if cb != nil { + cb(msg) + } + if _, ok := msg.(*types.Done); ok && again { + if findString(dt.dirs, name) < 0 { + dt.dirs = append(dt.dirs, name) + } + dt.buildTree() + dt.reindex(name) + } + }, force) +} + +func (dt *DirectoryTree) NextPrev(delta int) { + newIdx := dt.listIdx + ndirs := len(dt.list) + if newIdx == ndirs { + return + } + + if ndirs == 0 { + return + } + + step := 1 + if delta < 0 { + step = -1 + delta *= -1 + } + + for i := 0; i < delta; { + newIdx += step + if newIdx < 0 { + newIdx = ndirs - 1 + } else if newIdx >= ndirs { + newIdx = 0 + } + if isVisible(dt.list[newIdx]) { + i++ + } + } + + dt.selectIndex(newIdx) +} + +func (dt *DirectoryTree) selectIndex(i int) { + dt.listIdx = i + node := dt.list[dt.listIdx] + if node.Dummy { + dt.virtual = true + dt.NewContext() + dt.virtualCb() + } else { + dt.virtual = false + dt.Select(dt.getDirectory(node)) + } +} + +func (dt *DirectoryTree) CollapseFolder(name string) { + name = strings.TrimRight(name, dt.worker.PathSeparator()) + index, node := dt.getTreeNode(models.UID(name)) + if node == nil { + return + } + if node.Parent != nil && (node.Hidden != 0 || node.FirstChild == nil) { + node.Parent.Hidden = 1 + // highlight parent node and select it + for i, t := range dt.list { + if t == node.Parent && index == dt.listIdx { + dt.selectIndex(i) + break + } + } + } else { + node.Hidden = 1 + } + dt.Invalidate() +} + +func (dt *DirectoryTree) ExpandFolder(name string) { + name = strings.TrimRight(name, dt.worker.PathSeparator()) + _, node := dt.getTreeNode(models.UID(name)) + if node == nil { + return + } + node.Hidden = 0 + dt.Invalidate() +} + +func (dt *DirectoryTree) countVisible(list []*types.Thread) (n int) { + for _, node := range list { + if isVisible(node) { + n++ + } + } + return +} + +func (dt *DirectoryTree) nodeElems(node *types.Thread) []string { + dir := string(node.Uid) + sep := dt.DirectoryList.worker.PathSeparator() + return strings.Split(dir, sep) +} + +func (dt *DirectoryTree) nodeName(node *types.Thread) string { + if elems := dt.nodeElems(node); len(elems) > 0 { + return elems[len(elems)-1] + } + return "" +} + +func (dt *DirectoryTree) displayText(node *types.Thread) string { + return fmt.Sprintf("%s%s%s", + threadPrefix(node, false, false), + getFlag(node), dt.nodeName(node)) +} + +func (dt *DirectoryTree) getDirectory(node *types.Thread) string { + return string(node.Uid) +} + +func (dt *DirectoryTree) getTreeNode(uid models.UID) (int, *types.Thread) { + for i, node := range dt.list { + if node.Uid == uid { + return i, node + } + } + return -1, nil +} + +func (dt *DirectoryTree) hiddenDirectories() map[string]bool { + hidden := make(map[string]bool, 0) + for _, node := range dt.list { + if node.Hidden != 0 && node.FirstChild != nil { + elems := dt.nodeElems(node) + if levels := countLevels(node); levels < len(elems) { + if node.FirstChild != nil && (levels+1) < len(elems) { + levels += 1 + } + if dirStr := strings.Join(elems[:levels], dt.DirectoryList.worker.PathSeparator()); dirStr != "" { + hidden[dirStr] = true + } + } + } + } + return hidden +} + +func (dt *DirectoryTree) setHiddenDirectories(hiddenDirs map[string]bool) { + log.Tracef("setHiddenDirectories: %#v", hiddenDirs) + for _, node := range dt.list { + elems := dt.nodeElems(node) + if levels := countLevels(node); levels < len(elems) { + if node.FirstChild != nil && (levels+1) < len(elems) { + levels += 1 + } + strDir := strings.Join(elems[:levels], dt.DirectoryList.worker.PathSeparator()) + if hidden, ok := hiddenDirs[strDir]; hidden && ok { + node.Hidden = 1 + log.Tracef("setHiddenDirectories: %q -> %#v", strDir, node) + } + } + } +} + +func (dt *DirectoryTree) buildTree() { + if len(dt.list) != 0 { + hiddenDirs := dt.hiddenDirectories() + defer dt.setHiddenDirectories(hiddenDirs) + } + + dirs := make([]string, len(dt.dirs)) + copy(dirs, dt.dirs) + root := &types.Thread{} + dt.buildTreeNode(root, dirs, 1) + + var threads []*types.Thread + for iter := root.FirstChild; iter != nil; iter = iter.NextSibling { + iter.Parent = nil + threads = append(threads, iter) + } + + // folders-sort + if dt.DirectoryList.acctConf.EnableFoldersSort { + sort.Slice(threads, func(i, j int) bool { + foldersSort := dt.DirectoryList.acctConf.FoldersSort + iInFoldersSort := findString(foldersSort, dt.getDirectory(threads[i])) + jInFoldersSort := findString(foldersSort, dt.getDirectory(threads[j])) + if iInFoldersSort >= 0 && jInFoldersSort >= 0 { + return iInFoldersSort < jInFoldersSort + } + if iInFoldersSort >= 0 { + return true + } + if jInFoldersSort >= 0 { + return false + } + return dt.getDirectory(threads[i]) < dt.getDirectory(threads[j]) + }) + } + + dt.list = make([]*types.Thread, 0) + for _, node := range threads { + err := node.Walk(func(t *types.Thread, lvl int, err error) error { + dt.list = append(dt.list, t) + return nil + }) + if err != nil { + log.Warnf("failed to walk tree: %v", err) + } + } +} + +func (dt *DirectoryTree) buildTreeNode(node *types.Thread, dirs []string, depth int) { + dirmap := make(map[string][]string) + for _, dir := range dirs { + base, dir, cut := strings.Cut( + dir, dt.DirectoryList.worker.PathSeparator()) + if _, found := dirmap[base]; found { + if cut { + dirmap[base] = append(dirmap[base], dir) + } + } else if cut { + dirmap[base] = append(dirmap[base], dir) + } else { + dirmap[base] = []string{} + } + } + bases := make([]string, 0, len(dirmap)) + for base, dirs := range dirmap { + bases = append(bases, base) + sort.Strings(dirs) + } + sort.Strings(bases) + + basePath := dt.getDirectory(node) + collapse := dt.UiConfig(basePath).DirListCollapse + if collapse != 0 && depth > collapse { + node.Hidden = 1 + } else { + node.Hidden = 0 + } + + for _, base := range bases { + path := dt.childPath(basePath, base) + nextNode := &types.Thread{Uid: models.UID(path)} + + nextNode.Dummy = findString(dt.dirs, path) == -1 + + node.AddChild(nextNode) + dt.buildTreeNode(nextNode, dirmap[base], depth+1) + } +} + +func (dt *DirectoryTree) childPath(base, relpath string) string { + if base == "" { + return relpath + } + return base + dt.DirectoryList.worker.PathSeparator() + relpath +} + +func makeVisible(node *types.Thread) { + if node == nil { + return + } + for iter := node.Parent; iter != nil; iter = iter.Parent { + iter.Hidden = 0 + } +} + +func isVisible(node *types.Thread) bool { + for iter := node.Parent; iter != nil; iter = iter.Parent { + if iter.Hidden != 0 { + return false + } + } + return true +} + +func countLevels(node *types.Thread) (level int) { + for iter := node.Parent; iter != nil; iter = iter.Parent { + level++ + } + return +} + +func getFlag(node *types.Thread) string { + if node == nil || node.FirstChild == nil { + return "" + } + if node.Hidden != 0 { + return "+" + } + return "" +} diff --git a/app/exline.go b/app/exline.go new file mode 100644 index 0000000..20be8a4 --- /dev/null +++ b/app/exline.go @@ -0,0 +1,125 @@ +package app + +import ( + "context" + + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/lib" + "git.sr.ht/~rjarry/aerc/lib/ui" + "git.sr.ht/~rjarry/go-opt/v2" + "git.sr.ht/~rockorager/vaxis" +) + +type ExLine struct { + commit func(cmd string) + finish func() + tabcomplete func(ctx context.Context, cmd string) ([]opt.Completion, string) + cmdHistory lib.History + input *ui.TextInput +} + +func NewExLine(cmd string, commit func(cmd string), finish func(), + tabcomplete func(ctx context.Context, cmd string) ([]opt.Completion, string), + cmdHistory lib.History, +) *ExLine { + input := ui.NewTextInput("", config.Ui).Prompt(":").Set(cmd) + if config.Ui.CompletionPopovers { + input.TabComplete( + tabcomplete, + config.Ui.CompletionDelay, + config.Ui.CompletionMinChars, + &config.Binds.Global.CompleteKey, + ) + } + exline := &ExLine{ + commit: commit, + finish: finish, + tabcomplete: tabcomplete, + cmdHistory: cmdHistory, + input: input, + } + return exline +} + +func (x *ExLine) TabComplete(tabComplete func(context.Context, string) ([]opt.Completion, string)) { + x.input.TabComplete( + tabComplete, + config.Ui.CompletionDelay, + config.Ui.CompletionMinChars, + &config.Binds.Global.CompleteKey, + ) +} + +func NewPrompt(prompt string, commit func(text string), + tabcomplete func(ctx context.Context, cmd string) ([]opt.Completion, string), +) *ExLine { + input := ui.NewTextInput("", config.Ui).Prompt(prompt) + if config.Ui.CompletionPopovers { + input.TabComplete( + tabcomplete, + config.Ui.CompletionDelay, + config.Ui.CompletionMinChars, + &config.Binds.Global.CompleteKey, + ) + } + exline := &ExLine{ + commit: commit, + tabcomplete: tabcomplete, + cmdHistory: &nullHistory{input: input}, + input: input, + } + return exline +} + +func (ex *ExLine) Invalidate() { + ui.Invalidate() +} + +func (ex *ExLine) Draw(ctx *ui.Context) { + ex.input.Draw(ctx) +} + +func (ex *ExLine) Focus(focus bool) { + ex.input.Focus(focus) +} + +func (ex *ExLine) Event(event vaxis.Event) bool { + if key, ok := event.(vaxis.Key); ok { + switch { + case key.Matches(vaxis.KeyEnter), key.Matches('j', vaxis.ModCtrl): + cmd := ex.input.String() + ex.input.Focus(false) + ex.commit(cmd) + ex.finish() + case key.Matches(vaxis.KeyUp): + ex.input.Set(ex.cmdHistory.Prev()) + ex.Invalidate() + case key.Matches(vaxis.KeyDown): + ex.input.Set(ex.cmdHistory.Next()) + ex.Invalidate() + case key.Matches(vaxis.KeyEsc), key.Matches('c', vaxis.ModCtrl): + ex.input.Focus(false) + ex.cmdHistory.Reset() + ex.finish() + default: + return ex.input.Event(event) + } + } + return true +} + +type nullHistory struct { + input *ui.TextInput +} + +func (*nullHistory) Add(string) {} + +func (h *nullHistory) Next() string { + return h.input.String() +} + +func (h *nullHistory) Prev() string { + return h.input.String() +} + +func (*nullHistory) Reset() {} diff --git a/app/getpasswd.go b/app/getpasswd.go new file mode 100644 index 0000000..42934f5 --- /dev/null +++ b/app/getpasswd.go @@ -0,0 +1,67 @@ +package app + +import ( + "fmt" + + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/lib/ui" + "git.sr.ht/~rockorager/vaxis" +) + +type GetPasswd struct { + callback func(string, error) + title string + prompt string + input *ui.TextInput +} + +func NewGetPasswd( + title string, prompt string, cb func(string, error), +) *GetPasswd { + getpasswd := &GetPasswd{ + callback: cb, + title: title, + prompt: prompt, + input: ui.NewTextInput("", config.Ui).Password(true).Prompt("Password: "), + } + getpasswd.input.Focus(true) + return getpasswd +} + +func (gp *GetPasswd) Draw(ctx *ui.Context) { + defaultStyle := config.Ui.GetStyle(config.STYLE_DEFAULT) + titleStyle := config.Ui.GetStyle(config.STYLE_TITLE) + + ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', defaultStyle) + ctx.Fill(0, 0, ctx.Width(), 1, ' ', titleStyle) + ctx.Printf(1, 0, titleStyle, "%s", gp.title) + ctx.Printf(1, 1, defaultStyle, "%s", gp.prompt) + gp.input.Draw(ctx.Subcontext(1, 3, ctx.Width()-2, 1)) +} + +func (gp *GetPasswd) Invalidate() { + ui.Invalidate() +} + +func (gp *GetPasswd) Event(event vaxis.Event) bool { + switch event := event.(type) { + case vaxis.Key: + switch { + case event.Matches(vaxis.KeyEnter): + gp.input.Focus(false) + gp.callback(gp.input.String(), nil) + case event.Matches(vaxis.KeyEsc): + gp.input.Focus(false) + gp.callback("", fmt.Errorf("no password provided")) + default: + gp.input.Event(event) + } + default: + gp.input.Event(event) + } + return true +} + +func (gp *GetPasswd) Focus(f bool) { + // Who cares +} diff --git a/app/headerlayout.go b/app/headerlayout.go new file mode 100644 index 0000000..d9304de --- /dev/null +++ b/app/headerlayout.go @@ -0,0 +1,44 @@ +package app + +import ( + "git.sr.ht/~rjarry/aerc/lib/ui" + "git.sr.ht/~rjarry/aerc/models" +) + +type HeaderLayout [][]string + +type HeaderLayoutFilter struct { + layout HeaderLayout + keep func(msg *models.MessageInfo, header string) bool // filter criteria +} + +// forMessage returns a filtered header layout, removing rows whose headers +// do not appear in the provided message. +func (filter HeaderLayoutFilter) forMessage(msg *models.MessageInfo) HeaderLayout { + result := make(HeaderLayout, 0, len(filter.layout)) + for _, row := range filter.layout { + // To preserve layout alignment, only hide rows if all columns are empty + for _, col := range row { + if filter.keep(msg, col) { + result = append(result, row) + break + } + } + } + return result +} + +// grid builds a ui grid, populating each cell by calling a callback function +// with the current header string. +func (layout HeaderLayout) grid(cb func(string) ui.Drawable) (grid *ui.Grid, height int) { + rowCount := len(layout) + grid = ui.MakeGrid(rowCount, 1, ui.SIZE_EXACT, ui.SIZE_WEIGHT) + for i, cols := range layout { + r := ui.MakeGrid(1, len(cols), ui.SIZE_EXACT, ui.SIZE_WEIGHT) + for j, col := range cols { + r.AddChild(cb(col)).At(0, j) + } + grid.AddChild(r).At(i, 0) + } + return grid, rowCount +} diff --git a/app/listbox.go b/app/listbox.go new file mode 100644 index 0000000..3f3d7f3 --- /dev/null +++ b/app/listbox.go @@ -0,0 +1,325 @@ +package app + +import ( + "math" + "strings" + "sync" + + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/ui" + "git.sr.ht/~rockorager/vaxis" + "github.com/mattn/go-runewidth" +) + +type ListBox struct { + Scrollable + title string + lines []string + selected string + cursorPos int + horizPos int + jump int + showCursor bool + showFilter bool + filterMutex sync.Mutex + filter *ui.TextInput + uiConfig *config.UIConfig + textFilter func([]string, string) []string + cb func(string) +} + +func NewListBox(title string, lines []string, uiConfig *config.UIConfig, cb func(string)) *ListBox { + lb := &ListBox{ + title: title, + lines: lines, + cursorPos: -1, + jump: -1, + uiConfig: uiConfig, + textFilter: nil, + cb: cb, + filter: ui.NewTextInput("", uiConfig), + } + lb.filter.OnChange(func(ti *ui.TextInput) { + var show bool + if ti.String() == "" { + show = false + } else { + show = true + } + lb.setShowFilterField(show) + lb.filter.Focus(show) + lb.Invalidate() + }) + lb.dedup() + return lb +} + +func (lb *ListBox) SetTextFilter(fn func([]string, string) []string) *ListBox { + lb.textFilter = fn + return lb +} + +func (lb *ListBox) dedup() { + dedupped := make([]string, 0, len(lb.lines)) + dedup := make(map[string]struct{}) + for _, line := range lb.lines { + if _, dup := dedup[line]; dup { + log.Warnf("ignore duplicate: %s", line) + continue + } + dedup[line] = struct{}{} + dedupped = append(dedupped, line) + } + lb.lines = dedupped +} + +func (lb *ListBox) setShowFilterField(b bool) { + lb.filterMutex.Lock() + defer lb.filterMutex.Unlock() + lb.showFilter = b +} + +func (lb *ListBox) showFilterField() bool { + lb.filterMutex.Lock() + defer lb.filterMutex.Unlock() + return lb.showFilter +} + +func (lb *ListBox) Draw(ctx *ui.Context) { + defaultStyle := lb.uiConfig.GetStyle(config.STYLE_DEFAULT) + titleStyle := lb.uiConfig.GetStyle(config.STYLE_TITLE) + w, h := ctx.Width(), ctx.Height() + ctx.Fill(0, 0, w, h, ' ', defaultStyle) + ctx.Fill(0, 0, w, 1, ' ', titleStyle) + ctx.Printf(0, 0, titleStyle, "%s", lb.title) + + y := 0 + if lb.showFilterField() { + y = 1 + x := ctx.Printf(0, y, defaultStyle, "Filter (%d/%d): ", + len(lb.filtered()), len(lb.lines)) + lb.filter.Draw(ctx.Subcontext(x, y, w-x, 1)) + } + + lb.drawBox(ctx.Subcontext(0, y+1, w, h-(y+1))) +} + +func (lb *ListBox) moveCursor(delta int) { + list := lb.filtered() + if len(list) == 0 { + return + } + lb.cursorPos += delta + if lb.cursorPos < 0 { + lb.cursorPos = 0 + } + if lb.cursorPos >= len(list) { + lb.cursorPos = len(list) - 1 + } + lb.selected = list[lb.cursorPos] + lb.showCursor = true + lb.horizPos = 0 +} + +func (lb *ListBox) moveHorizontal(delta int) { + lb.horizPos += delta + if lb.horizPos > len(lb.selected) { + lb.horizPos = len(lb.selected) + } + if lb.horizPos < 0 { + lb.horizPos = 0 + } +} + +func (lb *ListBox) filtered() []string { + term := lb.filter.String() + + if lb.textFilter != nil { + return lb.textFilter(lb.lines, term) + } + + list := make([]string, 0, len(lb.lines)) + for _, line := range lb.lines { + if strings.Contains(line, term) { + list = append(list, line) + } + } + return list +} + +func (lb *ListBox) drawBox(ctx *ui.Context) { + defaultStyle := lb.uiConfig.GetStyle(config.STYLE_DEFAULT) + selectedStyle := lb.uiConfig.GetComposedStyleSelected(config.STYLE_MSGLIST_DEFAULT, nil) + + w, h := ctx.Width(), ctx.Height() + lb.jump = h + list := lb.filtered() + + lb.UpdateScroller(ctx.Height(), len(list)) + scroll := 0 + lb.cursorPos = -1 + for i := 0; i < len(list); i++ { + if lb.selected == list[i] { + scroll = i + lb.cursorPos = i + break + } + } + lb.EnsureScroll(scroll) + + needScrollbar := lb.NeedScrollbar() + if needScrollbar { + w -= 1 + if w < 0 { + w = 0 + } + } + + if lb.lines == nil || len(list) == 0 { + return + } + + y := 0 + for i := lb.Scroll(); i < len(list) && y < h; i++ { + style := defaultStyle + line := runewidth.Truncate(list[i], w-1, "❯") + if lb.selected == list[i] && lb.showCursor { + style = selectedStyle + if len(list[i]) > w { + if len(list[i])-lb.horizPos < w { + lb.horizPos = len(list[i]) - w + 1 + } + rest := list[i][lb.horizPos:] + line = runewidth.Truncate(rest, + w-1, "❯") + if lb.horizPos > 0 && len(line) > 0 { + line = "❮" + line[1:] + } + } + } + ctx.Printf(1, y, style, "%s", line) + y += 1 + } + + if needScrollbar { + scrollBarCtx := ctx.Subcontext(w, 0, 1, ctx.Height()) + lb.drawScrollbar(scrollBarCtx) + } +} + +func (lb *ListBox) drawScrollbar(ctx *ui.Context) { + gutterStyle := vaxis.Style{} + pillStyle := vaxis.Style{Attribute: vaxis.AttrReverse} + + // gutter + h := ctx.Height() + ctx.Fill(0, 0, 1, h, ' ', gutterStyle) + + // pill + pillSize := int(math.Ceil(float64(h) * lb.PercentVisible())) + pillOffset := int(math.Floor(float64(h) * lb.PercentScrolled())) + ctx.Fill(0, pillOffset, 1, pillSize, ' ', pillStyle) +} + +func (lb *ListBox) Invalidate() { + ui.Invalidate() +} + +func (lb *ListBox) Event(event vaxis.Event) bool { + showFilter := lb.showFilterField() + if key, ok := event.(vaxis.Key); ok { + switch { + case key.Matches(vaxis.KeyLeft): + if showFilter { + break + } + lb.moveHorizontal(-1) + lb.Invalidate() + return true + case key.Matches(vaxis.KeyRight): + if showFilter { + break + } + lb.moveHorizontal(+1) + lb.Invalidate() + return true + case key.Matches('b', vaxis.ModCtrl): + line := lb.selected[:lb.horizPos] + fds := strings.Fields(line) + if len(fds) > 1 { + lb.moveHorizontal( + strings.LastIndex(line, + fds[len(fds)-1]) - lb.horizPos - 1) + } else { + lb.horizPos = 0 + } + lb.Invalidate() + return true + case key.Matches('w', vaxis.ModCtrl): + line := lb.selected[lb.horizPos+1:] + fds := strings.Fields(line) + if len(fds) > 1 { + lb.moveHorizontal(strings.Index(line, fds[1])) + } + lb.Invalidate() + return true + case key.Matches('a', vaxis.ModCtrl), key.Matches(vaxis.KeyHome): + if showFilter { + break + } + lb.horizPos = 0 + lb.Invalidate() + return true + case key.Matches('e', vaxis.ModCtrl), key.Matches(vaxis.KeyEnd): + if showFilter { + break + } + lb.horizPos = len(lb.selected) + lb.Invalidate() + return true + case key.Matches('p', vaxis.ModCtrl), key.Matches(vaxis.KeyUp): + lb.moveCursor(-1) + lb.Invalidate() + return true + case key.Matches('n', vaxis.ModCtrl), key.Matches(vaxis.KeyDown): + lb.moveCursor(+1) + lb.Invalidate() + return true + case key.Matches(vaxis.KeyPgUp): + if lb.jump >= 0 { + lb.moveCursor(-lb.jump) + lb.Invalidate() + } + return true + case key.Matches(vaxis.KeyPgDown): + if lb.jump >= 0 { + lb.moveCursor(+lb.jump) + lb.Invalidate() + } + return true + case key.Matches(vaxis.KeyEnter): + return lb.quit(lb.selected) + case key.Matches(vaxis.KeyEsc): + return lb.quit("") + } + } + if lb.filter != nil { + handled := lb.filter.Event(event) + lb.Invalidate() + return handled + } + return false +} + +func (lb *ListBox) quit(s string) bool { + lb.filter.Focus(false) + if lb.cb != nil { + lb.cb(s) + } + return true +} + +func (lb *ListBox) Focus(f bool) { + lb.filter.Focus(f) +} diff --git a/app/msglist.go b/app/msglist.go new file mode 100644 index 0000000..42f6125 --- /dev/null +++ b/app/msglist.go @@ -0,0 +1,602 @@ +package app + +import ( + "bytes" + "math" + "strings" + + sortthread "github.com/emersion/go-imap-sortthread" + "github.com/emersion/go-message/mail" + "github.com/mattn/go-runewidth" + + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/lib" + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/state" + "git.sr.ht/~rjarry/aerc/lib/ui" + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/types" + "git.sr.ht/~rockorager/vaxis" +) + +type MessageList struct { + Scrollable + height int + width int + nmsgs int + spinner *Spinner + store *lib.MessageStore + isInitalizing bool +} + +func NewMessageList(account *AccountView) *MessageList { + ml := &MessageList{ + spinner: NewSpinner(account.UiConfig()), + isInitalizing: true, + } + // TODO: stop spinner, probably + ml.spinner.Start() + return ml +} + +func (ml *MessageList) Invalidate() { + ui.Invalidate() +} + +type messageRowParams struct { + uid models.UID + needsHeaders bool + err error + uiConfig *config.UIConfig + styles []config.StyleObject + headers *mail.Header +} + +// AlignMessage aligns the selected message to position pos. +func (ml *MessageList) AlignMessage(pos AlignPosition) { + store := ml.Store() + if store == nil { + return + } + idx := 0 + iter := store.UidsIterator() + for i := 0; iter.Next(); i++ { + if store.SelectedUid() == iter.Value().(models.UID) { + idx = i + break + } + } + ml.Align(idx, pos) +} + +func (ml *MessageList) Draw(ctx *ui.Context) { + ml.height = ctx.Height() + ml.width = ctx.Width() + uiConfig := SelectedAccountUiConfig() + ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', + uiConfig.GetStyle(config.STYLE_MSGLIST_DEFAULT)) + + acct := SelectedAccount() + store := ml.Store() + if store == nil || acct == nil || len(store.Uids()) == 0 { + if ml.isInitalizing { + ml.spinner.Draw(ctx) + } else { + ml.spinner.Stop() + ml.drawEmptyMessage(ctx) + } + return + } + + ml.SetOffset(uiConfig.MsglistScrollOffset) + ml.UpdateScroller(ml.height, len(store.Uids())) + iter := store.UidsIterator() + for i := 0; iter.Next(); i++ { + if store.SelectedUid() == iter.Value().(models.UID) { + ml.EnsureScroll(i) + break + } + } + + store.UpdateScroll(ml.Scroll(), ml.height) + + textWidth := ctx.Width() + if ml.NeedScrollbar() { + textWidth -= 1 + } + if textWidth <= 0 { + return + } + + var needsHeaders []models.UID + + data := state.NewDataSetter() + data.SetAccount(acct.acct) + data.SetFolder(acct.Directories().SelectedDirectory()) + + customDraw := func(t *ui.Table, r int, c *ui.Context) bool { + row := &t.Rows[r] + params, _ := row.Priv.(messageRowParams) + if params.err != nil { + var style vaxis.Style + if params.uid == store.SelectedUid() { + style = uiConfig.GetStyle(config.STYLE_ERROR) + } else { + style = uiConfig.GetStyleSelected(config.STYLE_ERROR) + } + ctx.Printf(0, r, style, "error: %s", params.err) + return true + } + if params.needsHeaders { + needsHeaders = append(needsHeaders, params.uid) + ml.spinner.Draw(ctx.Subcontext(0, r, c.Width(), 1)) + return true + } + return false + } + + getRowStyle := func(t *ui.Table, r int) vaxis.Style { + var style vaxis.Style + row := &t.Rows[r] + params, _ := row.Priv.(messageRowParams) + if params.uid == store.SelectedUid() { + style = params.uiConfig.MsgComposedStyleSelected( + config.STYLE_MSGLIST_DEFAULT, params.styles, + params.headers) + } else { + style = params.uiConfig.MsgComposedStyle( + config.STYLE_MSGLIST_DEFAULT, params.styles, + params.headers) + } + return style + } + + table := ui.NewTable( + ml.height, + uiConfig.IndexColumns, + uiConfig.ColumnSeparator, + customDraw, + getRowStyle, + ) + + showThreads := store.ThreadedView() + threadView := newThreadView(store) + iter = store.UidsIterator() + for i := 0; iter.Next(); i++ { + if i < ml.Scroll() { + continue + } + uid := iter.Value().(models.UID) + if showThreads { + threadView.Update(data, uid) + } + if addMessage(store, uid, &table, data, uiConfig) { + break + } + } + + table.Draw(ctx.Subcontext(0, 0, textWidth, ctx.Height())) + + if ml.NeedScrollbar() { + scrollbarCtx := ctx.Subcontext(textWidth, 0, 1, ctx.Height()) + ml.drawScrollbar(scrollbarCtx) + } + + if len(store.Uids()) == 0 { + if store.Sorting { + ml.spinner.Start() + ml.spinner.Draw(ctx) + return + } else { + ml.drawEmptyMessage(ctx) + } + } + + if len(needsHeaders) != 0 { + store.FetchHeaders(needsHeaders, nil) + ml.spinner.Start() + } else { + ml.spinner.Stop() + } +} + +func addMessage( + store *lib.MessageStore, uid models.UID, + table *ui.Table, data state.DataSetter, + uiConfig *config.UIConfig, +) bool { + msg := store.Messages[uid] + + cells := make([]string, len(table.Columns)) + params := messageRowParams{uid: uid, uiConfig: uiConfig} + + if msg == nil || (msg.Envelope == nil && msg.Error == nil) { + params.needsHeaders = true + return table.AddRow(cells, params) + } else if msg.Error != nil { + params.err = msg.Error + return table.AddRow(cells, params) + } + + if msg.Flags.Has(models.SeenFlag) { + params.styles = append(params.styles, config.STYLE_MSGLIST_READ) + } else { + params.styles = append(params.styles, config.STYLE_MSGLIST_UNREAD) + } + if msg.Flags.Has(models.AnsweredFlag) { + params.styles = append(params.styles, config.STYLE_MSGLIST_ANSWERED) + } + if msg.Flags.Has(models.ForwardedFlag) { + params.styles = append(params.styles, config.STYLE_MSGLIST_FORWARDED) + } + if msg.Flags.Has(models.FlaggedFlag) { + params.styles = append(params.styles, config.STYLE_MSGLIST_FLAGGED) + } + // deleted message + if _, ok := store.Deleted[msg.Uid]; ok { + params.styles = append(params.styles, config.STYLE_MSGLIST_DELETED) + } + // search result + if store.IsResult(msg.Uid) { + params.styles = append(params.styles, config.STYLE_MSGLIST_RESULT) + } + // folded thread + templateData, ok := data.(models.TemplateData) + if ok { + if templateData.ThreadFolded() { + params.styles = append(params.styles, config.STYLE_MSGLIST_THREAD_FOLDED) + } + if templateData.ThreadContext() { + params.styles = append(params.styles, config.STYLE_MSGLIST_THREAD_CONTEXT) + } + if templateData.ThreadOrphan() { + params.styles = append(params.styles, config.STYLE_MSGLIST_THREAD_ORPHAN) + } + } + // marked message + marked := store.Marker().IsMarked(msg.Uid) + if marked { + params.styles = append(params.styles, config.STYLE_MSGLIST_MARKED) + } + + data.SetInfo(msg, len(table.Rows), marked) + + for c, col := range table.Columns { + var buf bytes.Buffer + err := col.Def.Template.Execute(&buf, data.Data()) + if err != nil { + log.Errorf("<%s> %s", msg.Envelope.MessageId, err) + cells[c] = err.Error() + } else { + cells[c] = buf.String() + } + } + + params.headers = msg.RFC822Headers + + return table.AddRow(cells, params) +} + +func (ml *MessageList) drawScrollbar(ctx *ui.Context) { + uiConfig := SelectedAccountUiConfig() + gutterStyle := uiConfig.GetStyle(config.STYLE_MSGLIST_GUTTER) + pillStyle := uiConfig.GetStyle(config.STYLE_MSGLIST_PILL) + + // gutter + ctx.Fill(0, 0, 1, ctx.Height(), ' ', gutterStyle) + + // pill + pillSize := int(math.Ceil(float64(ctx.Height()) * ml.PercentVisible())) + pillOffset := int(math.Floor(float64(ctx.Height()) * ml.PercentScrolled())) + ctx.Fill(0, pillOffset, 1, pillSize, ' ', pillStyle) +} + +func (ml *MessageList) MouseEvent(localX int, localY int, event vaxis.Event) { + if event, ok := event.(vaxis.Mouse); ok { + switch event.Button { + case vaxis.MouseLeftButton: + selectedMsg, ok := ml.Clicked(localX, localY) + if ok { + ml.Select(selectedMsg) + acct := SelectedAccount() + if acct == nil || acct.Messages().Empty() { + return + } + store := acct.Messages().Store() + msg := acct.Messages().Selected() + if msg == nil { + return + } + lib.NewMessageStoreView(msg, acct.UiConfig().AutoMarkRead, + store, CryptoProvider(), DecryptKeys, + func(view lib.MessageView, err error) { + if err != nil { + PushError(err.Error()) + return + } + viewer, err := NewMessageViewer(acct, view) + if err != nil { + PushError(err.Error()) + return + } + NewTab(viewer, msg.Envelope.Subject) + }) + } + case vaxis.MouseWheelDown: + if ml.store != nil { + ml.store.Next() + } + ml.Invalidate() + case vaxis.MouseWheelUp: + if ml.store != nil { + ml.store.Prev() + } + ml.Invalidate() + } + } +} + +func (ml *MessageList) Clicked(x, y int) (int, bool) { + store := ml.Store() + if store == nil || ml.nmsgs == 0 || y >= ml.nmsgs { + return 0, false + } + return y + ml.Scroll(), true +} + +func (ml *MessageList) Height() int { + return ml.height +} + +func (ml *MessageList) Width() int { + return ml.width +} + +func (ml *MessageList) storeUpdate(store *lib.MessageStore) { + if ml.Store() != store { + return + } + ml.Invalidate() +} + +func (ml *MessageList) SetStore(store *lib.MessageStore) { + if ml.Store() != store { + ml.Scrollable = Scrollable{} + } + ml.store = store + if store != nil { + ml.spinner.Stop() + uids := store.Uids() + ml.nmsgs = len(uids) + store.OnUpdate(ml.storeUpdate) + store.OnFilterChange(func(store *lib.MessageStore) { + if ml.Store() != store { + return + } + ml.nmsgs = len(store.Uids()) + }) + } else { + ml.spinner.Start() + } + ml.Invalidate() +} + +func (ml *MessageList) SetInitDone() { + ml.isInitalizing = false +} + +func (ml *MessageList) Store() *lib.MessageStore { + return ml.store +} + +func (ml *MessageList) Empty() bool { + store := ml.Store() + return store == nil || len(store.Uids()) == 0 +} + +func (ml *MessageList) Selected() *models.MessageInfo { + return ml.Store().Selected() +} + +func (ml *MessageList) Select(index int) { + // Note that the msgstore.Select function expects a uid as argument + // whereas the msglist.Select expects the message number + store := ml.Store() + uids := store.Uids() + if len(uids) == 0 { + store.Select(lib.MagicUid) + return + } + + iter := store.UidsIterator() + + var uid models.UID + if index < 0 { + uid = uids[iter.EndIndex()] + } else { + uid = uids[iter.StartIndex()] + for i := 0; iter.Next(); i++ { + if i >= index { + uid = iter.Value().(models.UID) + break + } + } + } + store.Select(uid) + + ml.Invalidate() +} + +func (ml *MessageList) drawEmptyMessage(ctx *ui.Context) { + uiConfig := SelectedAccountUiConfig() + msg := uiConfig.EmptyMessage + ctx.Printf((ctx.Width()/2)-(len(msg)/2), 0, + uiConfig.GetStyle(config.STYLE_MSGLIST_DEFAULT), "%s", msg) +} + +func countThreads(thread *types.Thread) (ctr int) { + if thread == nil { + return + } + _ = thread.Walk(func(t *types.Thread, _ int, _ error) error { + ctr++ + return nil + }) + return +} + +func unreadInThread(thread *types.Thread, store *lib.MessageStore) (ctr int) { + if thread == nil { + return + } + _ = thread.Walk(func(t *types.Thread, _ int, _ error) error { + msg := store.Messages[t.Uid] + if msg != nil && !msg.Flags.Has(models.SeenFlag) { + ctr++ + } + return nil + }) + return +} + +func threadPrefix(t *types.Thread, reverse bool, msglist bool) string { + uiConfig := SelectedAccountUiConfig() + var tip, prefix, firstChild, lastSibling, orphan, dummy string + if msglist { + tip = uiConfig.ThreadPrefixTip + } else { + threadPrefixSibling := "├─" + threadPrefixReverse := "┌─" + threadPrefixEnd := "└─" + threadStem := "│" + threadIndent := strings.Repeat(" ", runewidth.StringWidth(threadPrefixSibling)-1) + + switch { + case t.Parent != nil && t.NextSibling != nil: + prefix += threadPrefixSibling + case t.Parent != nil && reverse: + prefix += threadPrefixReverse + case t.Parent != nil: + prefix += threadPrefixEnd + } + + for n := t.Parent; n != nil && n.Parent != nil; n = n.Parent { + if n.NextSibling != nil { + prefix = threadStem + threadIndent + prefix + } else { + prefix = " " + threadIndent + prefix + } + } + + return prefix + } + + if reverse { + firstChild = uiConfig.ThreadPrefixFirstChildReverse + lastSibling = uiConfig.ThreadPrefixLastSiblingReverse + orphan = uiConfig.ThreadPrefixOrphanReverse + dummy = uiConfig.ThreadPrefixDummyReverse + } else { + firstChild = uiConfig.ThreadPrefixFirstChild + lastSibling = uiConfig.ThreadPrefixLastSibling + orphan = uiConfig.ThreadPrefixOrphan + dummy = uiConfig.ThreadPrefixDummy + } + + var hiddenOffspring bool = t.FirstChild != nil && t.FirstChild.Hidden > 0 + var parentAndSiblings bool = t.Parent != nil && t.NextSibling != nil + var hasSiblings string = uiConfig.ThreadPrefixHasSiblings + if t.Parent != nil && t.Parent.Hidden > 0 && t.Hidden == 0 { + hasSiblings = dummy + } + + switch { + case parentAndSiblings && hiddenOffspring: + prefix = hasSiblings + + uiConfig.ThreadPrefixFolded + case parentAndSiblings && t.FirstChild != nil: + prefix = hasSiblings + + firstChild + tip + case parentAndSiblings: + prefix = hasSiblings + + uiConfig.ThreadPrefixLimb + + uiConfig.ThreadPrefixUnfolded + tip + case t.Parent != nil && hiddenOffspring: + prefix = lastSibling + uiConfig.ThreadPrefixFolded + case t.Parent != nil && t.FirstChild != nil: + prefix = lastSibling + firstChild + tip + case t.Parent != nil && t.FirstChild == nil: + prefix = lastSibling + uiConfig.ThreadPrefixLimb + tip + case t.Parent != nil: + prefix = lastSibling + uiConfig.ThreadPrefixUnfolded + + uiConfig.ThreadPrefixTip + case t.Parent == nil && hiddenOffspring: + prefix = uiConfig.ThreadPrefixFolded + case t.Parent == nil && t.Dummy: + prefix = dummy + tip + case t.Parent == nil && t.FirstChild != nil: + prefix = orphan + case t.Parent == nil && t.FirstChild == nil: + prefix = uiConfig.ThreadPrefixLone + } + + for n := t.Parent; n != nil && n.Parent != nil; n = n.Parent { + if n.NextSibling != nil { + prefix = uiConfig.ThreadPrefixStem + + uiConfig.ThreadPrefixIndent + prefix + } else { + prefix = " " + uiConfig.ThreadPrefixIndent + prefix + } + } + + return prefix +} + +func sameParent(left, right *types.Thread) bool { + return left.Root() == right.Root() +} + +func isParent(t *types.Thread) bool { + return t == t.Root() +} + +func threadSubject(store *lib.MessageStore, thread *types.Thread) string { + msg, found := store.Messages[thread.Uid] + if !found || msg == nil || msg.Envelope == nil { + return "" + } + subject, _ := sortthread.GetBaseSubject(msg.Envelope.Subject) + return subject +} + +type threadView struct { + store *lib.MessageStore + reverse bool + prev *types.Thread + prevSubj string +} + +func newThreadView(store *lib.MessageStore) *threadView { + return &threadView{ + store: store, + reverse: store.ReverseThreadOrder(), + } +} + +func (t *threadView) Update(data state.DataSetter, uid models.UID) { + thread, err := t.store.Thread(uid) + info := state.ThreadInfo{} + if thread != nil && err == nil { + info.Prefix = threadPrefix(thread, t.reverse, true) + subject := threadSubject(t.store, thread) + info.SameSubject = subject == t.prevSubj && sameParent(thread, t.prev) && !isParent(thread) + t.prev = thread + t.prevSubj = subject + info.Count = countThreads(thread) + info.Unread = unreadInThread(thread, t.store) + info.Folded = thread.FirstChild != nil && thread.FirstChild.Hidden != 0 + info.Context = thread.Context + info.Orphan = thread.Parent != nil && thread.Parent.Hidden > 0 && thread.Hidden == 0 + } + data.SetThreading(info) +} diff --git a/app/msgviewer.go b/app/msgviewer.go new file mode 100644 index 0000000..d093b11 --- /dev/null +++ b/app/msgviewer.go @@ -0,0 +1,914 @@ +package app + +import ( + "bytes" + "errors" + "fmt" + "image" + "io" + "os" + "os/exec" + "strings" + "sync/atomic" + + "github.com/danwakefield/fnmatch" + "github.com/emersion/go-message/textproto" + "github.com/mattn/go-runewidth" + + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/lib" + "git.sr.ht/~rjarry/aerc/lib/auth" + "git.sr.ht/~rjarry/aerc/lib/format" + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/parse" + "git.sr.ht/~rjarry/aerc/lib/ui" + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/go-opt/v2" + "git.sr.ht/~rockorager/vaxis" + "git.sr.ht/~rockorager/vaxis/widgets/align" + + // Image support + _ "image/jpeg" + _ "image/png" + + _ "golang.org/x/image/bmp" + _ "golang.org/x/image/tiff" + _ "golang.org/x/image/webp" +) + +// All imported image types need to be explicitly stated here. We want to check +// if we _can_ display something before we download it +var supportedImageTypes = []string{ + "image/jpeg", + "image/png", + "image/bmp", + "image/tiff", + "image/webp", +} + +var _ ProvidesMessages = (*MessageViewer)(nil) + +type MessageViewer struct { + acct *AccountView + grid *ui.Grid + switcher *PartSwitcher + msg lib.MessageView + uiConfig *config.UIConfig +} + +func NewMessageViewer( + acct *AccountView, msg lib.MessageView, +) (*MessageViewer, error) { + if msg == nil { + return &MessageViewer{acct: acct}, nil + } + hf := HeaderLayoutFilter{ + layout: HeaderLayout(config.Viewer.HeaderLayout), + keep: func(msg *models.MessageInfo, header string) bool { + return fmtHeader(msg, header, "2", "3", "4", "5") != "" + }, + } + layout := hf.forMessage(msg.MessageInfo()) + header, headerHeight := layout.grid( + func(header string) ui.Drawable { + hv := &HeaderView{ + Name: header, + Value: fmtHeader( + msg.MessageInfo(), + header, + acct.UiConfig().MessageViewTimestampFormat, + acct.UiConfig().MessageViewThisDayTimeFormat, + acct.UiConfig().MessageViewThisWeekTimeFormat, + acct.UiConfig().MessageViewThisYearTimeFormat, + ), + uiConfig: acct.UiConfig(), + } + showInfo := false + if i := strings.IndexRune(header, '+'); i > 0 { + header = header[:i] + hv.Name = header + showInfo = true + } + if parser := auth.New(header); parser != nil && msg.MessageInfo().Error == nil { + details, err := parser(msg.MessageInfo().RFC822Headers, acct.AccountConfig().TrustedAuthRes) + if err != nil { + hv.Value = err.Error() + } else { + hv.ValueField = NewAuthInfo(details, showInfo, acct.UiConfig()) + } + hv.Invalidate() + } + return hv + }, + ) + + rows := []ui.GridSpec{ + {Strategy: ui.SIZE_EXACT, Size: ui.Const(headerHeight)}, + } + + if msg.MessageDetails() != nil || acct.UiConfig().IconUnencrypted != "" { + height := 1 + if msg.MessageDetails() != nil && msg.MessageDetails().IsSigned && msg.MessageDetails().IsEncrypted { + height = 2 + } + rows = append(rows, ui.GridSpec{Strategy: ui.SIZE_EXACT, Size: ui.Const(height)}) + } + + rows = append(rows, []ui.GridSpec{ + {Strategy: ui.SIZE_EXACT, Size: ui.Const(1)}, + {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)}, + }...) + + grid := ui.NewGrid().Rows(rows).Columns([]ui.GridSpec{ + {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)}, + }) + + switcher := &PartSwitcher{} + err := createSwitcher(acct, switcher, msg) + if err != nil { + return nil, err + } + + borderStyle := acct.UiConfig().GetStyle(config.STYLE_BORDER) + borderChar := acct.UiConfig().BorderCharHorizontal + + grid.AddChild(header).At(0, 0) + if msg.MessageDetails() != nil || acct.UiConfig().IconUnencrypted != "" { + grid.AddChild(NewPGPInfo(msg.MessageDetails(), acct.UiConfig())).At(1, 0) + grid.AddChild(ui.NewFill(borderChar, borderStyle)).At(2, 0) + grid.AddChild(switcher).At(3, 0) + } else { + grid.AddChild(ui.NewFill(borderChar, borderStyle)).At(1, 0) + grid.AddChild(switcher).At(2, 0) + } + + mv := &MessageViewer{ + acct: acct, + grid: grid, + msg: msg, + switcher: switcher, + uiConfig: acct.UiConfig(), + } + switcher.uiConfig = mv.uiConfig + + return mv, nil +} + +func fmtHeader(msg *models.MessageInfo, header string, + timefmt string, todayFormat string, thisWeekFormat string, thisYearFormat string, +) string { + if msg == nil || msg.Envelope == nil { + return "error: no envelope for this message" + } + + if v := auth.New(header); v != nil { + return "Fetching.." + } + + switch header { + case "From": + return format.FormatAddresses(msg.Envelope.From) + case "Sender": + return format.FormatAddresses(msg.Envelope.Sender) + case "To": + return format.FormatAddresses(msg.Envelope.To) + case "Cc": + return format.FormatAddresses(msg.Envelope.Cc) + case "Bcc": + return format.FormatAddresses(msg.Envelope.Bcc) + case "Date": + return format.DummyIfZeroDate( + msg.Envelope.Date.Local(), + timefmt, + todayFormat, + thisWeekFormat, + thisYearFormat, + ) + case "Subject": + return msg.Envelope.Subject + case "Labels": + return strings.Join(msg.Labels, ", ") + default: + return msg.RFC822Headers.Get(header) + } +} + +func enumerateParts( + acct *AccountView, msg lib.MessageView, + body *models.BodyStructure, index []int, +) ([]*PartViewer, error) { + var parts []*PartViewer + for i, part := range body.Parts { + curindex := append(index, i+1) //nolint:gocritic // intentional append to different slice + if part.MIMEType == "multipart" { + // Multipart meta-parts are faked + pv := &PartViewer{part: part} + parts = append(parts, pv) + subParts, err := enumerateParts( + acct, msg, part, curindex) + if err != nil { + return nil, err + } + parts = append(parts, subParts...) + continue + } + pv, err := NewPartViewer(acct, msg, part, curindex) + if err != nil { + return nil, err + } + parts = append(parts, pv) + } + return parts, nil +} + +func createSwitcher( + acct *AccountView, switcher *PartSwitcher, msg lib.MessageView, +) error { + var err error + switcher.selected = -1 + + if msg.MessageInfo().Error != nil { + return fmt.Errorf("could not view message: %w", msg.MessageInfo().Error) + } + + if len(msg.BodyStructure().Parts) == 0 { + switcher.selected = 0 + pv, err := NewPartViewer(acct, msg, msg.BodyStructure(), nil) + if err != nil { + return err + } + switcher.parts = []*PartViewer{pv} + } else { + switcher.parts, err = enumerateParts(acct, msg, + msg.BodyStructure(), []int{}) + if err != nil { + return err + } + selectedPriority := -1 + log.Tracef("Selecting best message from %v", config.Viewer.Alternatives) + for i, pv := range switcher.parts { + // Switch to user's preferred mimetype + if switcher.selected == -1 && pv.part.MIMEType != "multipart" { + switcher.selected = i + } + mime := pv.part.FullMIMEType() + for idx, m := range config.Viewer.Alternatives { + if m != mime { + continue + } + priority := len(config.Viewer.Alternatives) - idx + if priority > selectedPriority { + selectedPriority = priority + switcher.selected = i + } + } + } + } + return nil +} + +func (mv *MessageViewer) Draw(ctx *ui.Context) { + if mv.switcher == nil { + style := mv.acct.UiConfig().GetStyle(config.STYLE_DEFAULT) + ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', style) + ctx.Printf(0, 0, style, "%s", "(no message selected)") + return + } + mv.grid.Draw(ctx) +} + +func (mv *MessageViewer) MouseEvent(localX int, localY int, event vaxis.Event) { + if mv.switcher == nil { + return + } + mv.grid.MouseEvent(localX, localY, event) +} + +func (mv *MessageViewer) Invalidate() { + ui.Invalidate() +} + +func (mv *MessageViewer) Terminal() *Terminal { + if mv.switcher == nil { + return nil + } + + nparts := len(mv.switcher.parts) + if nparts == 0 || mv.switcher.selected < 0 || mv.switcher.selected >= nparts { + return nil + } + + pv := mv.switcher.parts[mv.switcher.selected] + if pv == nil { + return nil + } + + return pv.term +} + +func (mv *MessageViewer) Store() *lib.MessageStore { + return mv.msg.Store() +} + +func (mv *MessageViewer) SelectedAccount() *AccountView { + return mv.acct +} + +func (mv *MessageViewer) MessageView() lib.MessageView { + return mv.msg +} + +func (mv *MessageViewer) SelectedMessage() (*models.MessageInfo, error) { + if mv.msg == nil { + return nil, errors.New("no message selected") + } + return mv.msg.MessageInfo(), nil +} + +func (mv *MessageViewer) MarkedMessages() ([]models.UID, error) { + return mv.acct.MarkedMessages() +} + +func (mv *MessageViewer) ToggleHeaders() { + if mv.switcher == nil { + return + } + switcher := mv.switcher + switcher.Cleanup() + config.Viewer.ShowHeaders = !config.Viewer.ShowHeaders + err := createSwitcher(mv.acct, switcher, mv.msg) + if err != nil { + log.Errorf("cannot create switcher: %v", err) + } + switcher.Invalidate() +} + +func (mv *MessageViewer) ToggleKeyPassthrough() bool { + config.Viewer.KeyPassthrough = !config.Viewer.KeyPassthrough + return config.Viewer.KeyPassthrough +} + +func (mv *MessageViewer) SelectedMessagePart() *PartInfo { + if mv.switcher == nil { + return nil + } + part := mv.switcher.SelectedPart() + return &PartInfo{ + Index: part.index, + Msg: part.msg.MessageInfo(), + Part: part.part, + Links: part.links, + } +} + +func (mv *MessageViewer) AttachmentParts(all bool) []*PartInfo { + if mv.switcher == nil { + return nil + } + return mv.switcher.AttachmentParts(all) +} + +func (mv *MessageViewer) PreviousPart() { + if mv.switcher == nil { + return + } + mv.switcher.PreviousPart() + mv.Invalidate() +} + +func (mv *MessageViewer) NextPart() { + if mv.switcher == nil { + return + } + mv.switcher.NextPart() + mv.Invalidate() +} + +func (mv *MessageViewer) Bindings() string { + if config.Viewer.KeyPassthrough { + return "view::passthrough" + } else { + return "view" + } +} + +func (mv *MessageViewer) Close() { + if mv.switcher != nil { + mv.switcher.Cleanup() + } +} + +func (mv *MessageViewer) Event(event vaxis.Event) bool { + if mv.switcher != nil { + return mv.switcher.Event(event) + } + return false +} + +func (mv *MessageViewer) Focus(focus bool) { + if mv.switcher != nil { + mv.switcher.Focus(focus) + } +} + +func (mv *MessageViewer) Show(visible bool) { + if mv.switcher != nil { + mv.switcher.Show(visible) + } +} + +type PartViewer struct { + acctConfig *config.AccountConfig + err error + fetched bool + filter *exec.Cmd + index []int + msg lib.MessageView + pager *exec.Cmd + pagerin io.WriteCloser + part *models.BodyStructure + source io.Reader + term *Terminal + grid *ui.Grid + noFilter *ui.Grid + uiConfig *config.UIConfig + copying int32 + inlineImg bool + image image.Image + graphic vaxis.Image + width int + height int + + links []string +} + +const copying int32 = 1 + +func NewPartViewer( + acct *AccountView, msg lib.MessageView, part *models.BodyStructure, + curindex []int, +) (*PartViewer, error) { + var ( + filter *exec.Cmd + pager *exec.Cmd + pagerin io.WriteCloser + term *Terminal + ) + info := msg.MessageInfo() + mime := part.FullMIMEType() + + for _, f := range config.Filters { + switch f.Type { + case config.FILTER_MIMETYPE: + if fnmatch.Match(f.Filter, mime, 0) { + filter = exec.Command("sh", "-c", f.Command) + } + case config.FILTER_HEADER: + var header string + switch f.Header { + case "subject": + header = info.Envelope.Subject + case "from": + header = format.FormatAddresses(info.Envelope.From) + case "to": + header = format.FormatAddresses(info.Envelope.To) + case "cc": + header = format.FormatAddresses(info.Envelope.Cc) + default: + header = msg.MessageInfo().RFC822Headers.Get(f.Header) + } + if f.Regex.Match([]byte(header)) { + filter = exec.Command("sh", "-c", f.Command) + } + case config.FILTER_FILENAME: + if f.Regex.Match([]byte(part.DispositionParams["filename"])) { + filter = exec.Command("sh", "-c", f.Command) + log.Tracef("command %v", f.Command) + } + } + if filter == nil { + continue + } + if !f.NeedsPager { + pager = filter + break + } + pagerCmd, err := CmdFallbackSearch(config.PagerCmds(), false) + if err != nil { + acct.PushError(fmt.Errorf("could not start pager: %w", err)) + return nil, err + } + cmd := opt.SplitArgs(pagerCmd) + pager = exec.Command(cmd[0], cmd[1:]...) + break + } + var noFilter *ui.Grid + if filter != nil { + path, _ := os.LookupEnv("PATH") + var paths []string + for _, dir := range config.SearchDirs { + paths = append(paths, dir+"/filters") + } + paths = append(paths, path) + path = strings.Join(paths, ":") + filter.Env = os.Environ() + filter.Env = append(filter.Env, fmt.Sprintf("PATH=%s", path)) + filter.Env = append(filter.Env, + fmt.Sprintf("AERC_MIME_TYPE=%s", mime)) + filter.Env = append(filter.Env, + fmt.Sprintf("AERC_FILENAME=%s", part.FileName())) + if flowed, ok := part.Params["format"]; ok { + filter.Env = append(filter.Env, + fmt.Sprintf("AERC_FORMAT=%s", flowed)) + } + filter.Env = append(filter.Env, + fmt.Sprintf("AERC_SUBJECT=%s", info.Envelope.Subject)) + filter.Env = append(filter.Env, fmt.Sprintf("AERC_FROM=%s", + format.FormatAddresses(info.Envelope.From))) + filter.Env = append(filter.Env, fmt.Sprintf("AERC_STYLESET=%s", + acct.UiConfig().StyleSetPath())) + if config.General.EnableOSC8 { + filter.Env = append(filter.Env, "AERC_OSC8_URLS=1") + } + if pager == filter { + log.Debugf("<%s> part=%v %s: %v", + info.Envelope.MessageId, curindex, mime, filter) + } else { + log.Debugf("<%s> part=%v %s: %v | %v", + info.Envelope.MessageId, curindex, mime, filter, pager) + } + var err error + if pagerin, err = pager.StdinPipe(); err != nil { + return nil, err + } + if term, err = NewTerminal(pager); err != nil { + return nil, err + } + } else { + noFilter = newNoFilterConfigured(acct.Name(), part) + } + + grid := ui.NewGrid().Rows([]ui.GridSpec{ + {Strategy: ui.SIZE_EXACT, Size: ui.Const(3)}, // Message + {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)}, + }).Columns([]ui.GridSpec{ + {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)}, + }) + + index := make([]int, len(curindex)) + copy(index, curindex) + + pv := &PartViewer{ + acctConfig: acct.AccountConfig(), + filter: filter, + index: index, + msg: msg, + pager: pager, + pagerin: pagerin, + part: part, + term: term, + grid: grid, + noFilter: noFilter, + uiConfig: acct.UiConfig(), + } + + return pv, nil +} + +func (pv *PartViewer) SetSource(reader io.Reader) { + pv.source = reader + switch pv.inlineImg { + case true: + pv.decodeImage() + default: + pv.attemptCopy() + } +} + +func (pv *PartViewer) decodeImage() { + atomic.StoreInt32(&pv.copying, copying) + go func() { + defer log.PanicHandler() + defer pv.Invalidate() + defer atomic.StoreInt32(&pv.copying, 0) + img, _, err := image.Decode(pv.source) + if err != nil { + log.Errorf("error decoding image: %v", err) + return + } + pv.image = img + }() +} + +func (pv *PartViewer) attemptCopy() { + if pv.source == nil || + pv.filter == nil || + atomic.SwapInt32(&pv.copying, copying) == copying { + return + } + pv.writeMailHeaders() + if strings.EqualFold(pv.part.MIMEType, "text") { + pv.source = parse.StripAnsi(pv.hyperlinks(pv.source)) + } + if pv.filter != pv.pager { + // Filter is a separate process that needs to output to the pager. + pv.filter.Stdin = pv.source + pv.filter.Stdout = pv.pagerin + pv.filter.Stderr = pv.pagerin + err := pv.filter.Start() + if err != nil { + log.Errorf("error running filter: %v", err) + return + } + } + go func() { + defer log.PanicHandler() + defer atomic.StoreInt32(&pv.copying, 0) + var err error + if pv.filter == pv.pager { + // Filter already implements its own paging. + _, err = io.Copy(pv.pagerin, pv.source) + if err != nil { + log.Errorf("io.Copy: %s", err) + } + } else { + err = pv.filter.Wait() + if err != nil { + log.Errorf("filter.Wait: %v", err) + } + } + err = pv.pagerin.Close() + if err != nil { + log.Errorf("error closing pager pipe: %v", err) + } + }() +} + +func (pv *PartViewer) writeMailHeaders() { + info := pv.msg.MessageInfo() + if !config.Viewer.ShowHeaders || info.RFC822Headers == nil { + return + } + if pv.filter == pv.pager { + // Filter already implements its own paging. + // Piping another filter into it will cause mayhem. + return + } + var file io.WriteCloser + + for _, f := range config.Filters { + if f.Type != config.FILTER_HEADERS { + continue + } + log.Debugf("<%s> piping headers in filter: %s", + info.Envelope.MessageId, f.Command) + filter := exec.Command("sh", "-c", f.Command) + if pv.filter != nil { + // inherit from filter env + filter.Env = pv.filter.Env + } + + stdin, err := filter.StdinPipe() + if err == nil { + filter.Stdout = pv.pagerin + filter.Stderr = pv.pagerin + err := filter.Start() + if err == nil { + //nolint:errcheck // who cares? + defer filter.Wait() + file = stdin + } else { + log.Errorf( + "failed to start header filter: %v", + err) + } + } else { + log.Errorf("failed to create pipe: %v", err) + } + break + } + + if file == nil { + file = pv.pagerin + } else { + defer file.Close() + } + + var buf bytes.Buffer + err := textproto.WriteHeader(&buf, info.RFC822Headers.Header.Header) + if err != nil { + log.Errorf("failed to format headers: %v", err) + } + _, err = file.Write(bytes.TrimRight(buf.Bytes(), "\r\n")) + if err != nil { + log.Errorf("failed to write headers: %v", err) + } + + // virtual header + if len(info.Labels) != 0 { + labels := fmtHeader(info, "Labels", "", "", "", "") + _, err := file.Write([]byte(fmt.Sprintf("\r\nLabels: %s", labels))) + if err != nil { + log.Errorf("failed to write to labels: %v", err) + } + } + _, err = file.Write([]byte{'\r', '\n', '\r', '\n'}) + if err != nil { + log.Errorf("failed to write empty line: %v", err) + } +} + +func (pv *PartViewer) hyperlinks(r io.Reader) (reader io.Reader) { + if !config.Viewer.ParseHttpLinks { + return r + } + reader, pv.links = parse.HttpLinks(r) + return reader +} + +var noFilterConfiguredCommands = [][]string{ + {":open<enter>", "Open using the system handler"}, + {":save<space>", "Save to file"}, + {":pipe<space>", "Pipe to shell command"}, +} + +func newNoFilterConfigured(account string, part *models.BodyStructure) *ui.Grid { + bindings := config.Binds.MessageView.ForAccount(account) + + var actions []string + + configured := noFilterConfiguredCommands + if strings.Contains(strings.ToLower(part.MIMEType), "message") { + configured = append(configured, []string{ + ":eml<Enter>", "View message attachment", + }) + } + + for _, command := range configured { + cmd := command[0] + name := command[1] + strokes, _ := config.ParseKeyStrokes(cmd) + var inputs []string + for _, input := range bindings.GetReverseBindings(strokes) { + inputs = append(inputs, config.FormatKeyStrokes(input)) + } + actions = append(actions, fmt.Sprintf(" %-6s %-29s %s", + strings.Join(inputs, ", "), name, cmd)) + } + + spec := []ui.GridSpec{ + {Strategy: ui.SIZE_EXACT, Size: ui.Const(2)}, + } + for i := 0; i < len(actions)-1; i++ { + spec = append(spec, ui.GridSpec{Strategy: ui.SIZE_EXACT, Size: ui.Const(1)}) + } + // make the last element fill remaining space + spec = append(spec, ui.GridSpec{Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)}) + + grid := ui.NewGrid().Rows(spec).Columns([]ui.GridSpec{ + {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)}, + }) + + uiConfig := config.Ui.ForAccount(account) + + noFilter := fmt.Sprintf(`No filter configured for this mimetype ('%s') +What would you like to do?`, part.FullMIMEType()) + grid.AddChild(ui.NewText(noFilter, + uiConfig.GetStyle(config.STYLE_TITLE))).At(0, 0) + for i, action := range actions { + grid.AddChild(ui.NewText(action, + uiConfig.GetStyle(config.STYLE_DEFAULT))).At(i+1, 0) + } + + return grid +} + +func (pv *PartViewer) Invalidate() { + ui.Invalidate() +} + +func (pv *PartViewer) Draw(ctx *ui.Context) { + style := pv.uiConfig.GetStyle(config.STYLE_DEFAULT) + switch { + case pv.filter == nil && canInline(pv.part.FullMIMEType()) && pv.err == nil: + pv.inlineImg = true + case pv.filter == nil: + // No filter, can't inline, and/or we attempted to inline an image + // and resulted in an error (maybe because of a bad encoding or + // the terminal doesn't support any graphics protocol). + ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', style) + pv.noFilter.Draw(ctx) + return + case !pv.fetched: + w, h := ctx.Window().Size() + pv.filter.Env = append(pv.filter.Env, fmt.Sprintf("COLUMNS=%d", w)) + pv.filter.Env = append(pv.filter.Env, fmt.Sprintf("LINES=%d", h)) + } + if !pv.fetched { + pv.msg.FetchBodyPart(pv.index, pv.SetSource) + pv.fetched = true + } + if pv.err != nil { + ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', style) + ctx.Printf(0, 0, style, "%s", pv.err.Error()) + return + } + if pv.term != nil { + pv.term.Draw(ctx) + } + if pv.image != nil && (pv.resized(ctx) || pv.graphic == nil) { + // This path should only occur on resizes or the first pass + // after the image is downloaded and could be slow due to + // encoding the image to either sixel or uploading via the kitty + // protocol. Generally it's pretty fast since we will only ever + // be downsizing images + vx := ctx.Window().Vx + if pv.graphic == nil { + var err error + pv.graphic, err = vx.NewImage(pv.image) + if err != nil { + log.Errorf("Couldn't create image: %v", err) + return + } + } + pv.graphic.Resize(pv.width, pv.height) + } + if pv.graphic != nil { + w, h := pv.graphic.CellSize() + win := align.Center(ctx.Window(), w, h) + pv.graphic.Draw(win) + } +} + +func (pv *PartViewer) Cleanup() { + if pv.term != nil { + pv.term.Close() + } + if pv.graphic != nil { + pv.graphic.Destroy() + } +} + +func (pv *PartViewer) resized(ctx *ui.Context) bool { + w := ctx.Width() + h := ctx.Height() + if pv.width != w || pv.height != h { + pv.width = w + pv.height = h + return true + } + return false +} + +func (pv *PartViewer) Event(event vaxis.Event) bool { + if pv.term != nil { + return pv.term.Event(event) + } + return false +} + +type HeaderView struct { + Name string + Value string + ValueField ui.Drawable + uiConfig *config.UIConfig +} + +func (hv *HeaderView) Draw(ctx *ui.Context) { + name := hv.Name + size := runewidth.StringWidth(name + ":") + lim := ctx.Width() - size - 1 + if lim <= 0 || ctx.Height() <= 0 { + return + } + value := runewidth.Truncate(" "+hv.Value, lim, "…") + + vstyle := hv.uiConfig.GetStyle(config.STYLE_DEFAULT) + hstyle := hv.uiConfig.GetStyle(config.STYLE_HEADER) + + // TODO: Make this more robust and less dumb + if hv.Name == "PGP" { + vstyle = hv.uiConfig.GetStyle(config.STYLE_SUCCESS) + } + + ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', vstyle) + ctx.Printf(0, 0, hstyle, "%s:", name) + if hv.ValueField == nil { + ctx.Printf(size, 0, vstyle, "%s", value) + } else { + hv.ValueField.Draw(ctx.Subcontext(size, 0, lim, 1)) + } +} + +func (hv *HeaderView) Invalidate() { + ui.Invalidate() +} + +func canInline(mime string) bool { + for _, ext := range supportedImageTypes { + if mime == ext { + return true + } + } + return false +} diff --git a/app/partswitcher.go b/app/partswitcher.go new file mode 100644 index 0000000..5a935a8 --- /dev/null +++ b/app/partswitcher.go @@ -0,0 +1,207 @@ +package app + +import ( + "math" + + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/lib/ui" + "git.sr.ht/~rockorager/vaxis" + "github.com/mattn/go-runewidth" +) + +type PartSwitcher struct { + Scrollable + parts []*PartViewer + selected int + + height int + offset int + + uiConfig *config.UIConfig +} + +func (ps *PartSwitcher) PreviousPart() { + for { + ps.selected-- + if ps.selected < 0 { + ps.selected = len(ps.parts) - 1 + } + if ps.parts[ps.selected].part.MIMEType != "multipart" { + break + } + } +} + +func (ps *PartSwitcher) NextPart() { + for { + ps.selected++ + if ps.selected >= len(ps.parts) { + ps.selected = 0 + } + if ps.parts[ps.selected].part.MIMEType != "multipart" { + break + } + } +} + +func (ps *PartSwitcher) SelectedPart() *PartViewer { + return ps.parts[ps.selected] +} + +func (ps *PartSwitcher) AttachmentParts(all bool) []*PartInfo { + var attachments []*PartInfo + for _, p := range ps.parts { + if p.part.Disposition == "attachment" || (all && p.part.FileName() != "") { + pi := &PartInfo{ + Index: p.index, + Msg: p.msg.MessageInfo(), + Part: p.part, + } + attachments = append(attachments, pi) + } + } + return attachments +} + +func (ps *PartSwitcher) Invalidate() { + ui.Invalidate() +} + +func (ps *PartSwitcher) Focus(focus bool) { + if ps.parts[ps.selected].term != nil { + ps.parts[ps.selected].term.Focus(focus) + } +} + +func (ps *PartSwitcher) Show(visible bool) { + if ps.parts[ps.selected].term != nil { + ps.parts[ps.selected].term.Show(visible) + } +} + +func (ps *PartSwitcher) Event(event vaxis.Event) bool { + return ps.parts[ps.selected].Event(event) +} + +func (ps *PartSwitcher) Draw(ctx *ui.Context) { + uiConfig := ps.uiConfig + n := len(ps.parts) + if n == 1 && !config.Viewer.AlwaysShowMime { + ps.parts[ps.selected].Draw(ctx) + return + } + + ps.height = config.Viewer.MaxMimeHeight + if ps.height <= 0 || n < ps.height { + ps.height = n + } + if ps.height > ctx.Height()/2 { + ps.height = ctx.Height() / 2 + } + + ps.UpdateScroller(ps.height, n) + ps.EnsureScroll(ps.selected) + + var styleSwitcher, styleFile, styleMime vaxis.Style + + scrollbarWidth := 0 + if ps.NeedScrollbar() { + scrollbarWidth = 1 + } + + ps.offset = ctx.Height() - ps.height + y := ps.offset + row := ps.offset + ctx.Fill(0, y, ctx.Width(), ps.height, ' ', uiConfig.GetStyle(config.STYLE_PART_SWITCHER)) + for i := ps.Scroll(); i < n; i++ { + part := ps.parts[i] + if ps.selected == i { + styleSwitcher = uiConfig.GetStyleSelected(config.STYLE_PART_SWITCHER) + styleFile = uiConfig.GetStyleSelected(config.STYLE_PART_FILENAME) + styleMime = uiConfig.GetStyleSelected(config.STYLE_PART_MIMETYPE) + } else { + styleSwitcher = uiConfig.GetStyle(config.STYLE_PART_SWITCHER) + styleFile = uiConfig.GetStyle(config.STYLE_PART_FILENAME) + styleMime = uiConfig.GetStyle(config.STYLE_PART_MIMETYPE) + } + ctx.Fill(0, row, ctx.Width(), 1, ' ', styleSwitcher) + left := len(part.index) * 2 + if part.part.FileName() != "" { + name := runewidth.Truncate(part.part.FileName(), + ctx.Width()-left-1, "…") + left += ctx.Printf(left, row, styleFile, "%s ", name) + } + t := "(" + part.part.FullMIMEType() + ")" + t = runewidth.Truncate(t, ctx.Width()-left-scrollbarWidth, "…") + ctx.Printf(left, row, styleMime, "%s", t) + row++ + + if (i - ps.Scroll()) >= ps.height { + break + } + } + if ps.NeedScrollbar() { + ps.drawScrollbar(ctx.Subcontext(ctx.Width()-1, y, 1, ps.height)) + } + ps.parts[ps.selected].Draw(ctx.Subcontext( + 0, 0, ctx.Width(), ctx.Height()-ps.height)) +} + +func (ps *PartSwitcher) drawScrollbar(ctx *ui.Context) { + uiConfig := ps.uiConfig + gutterStyle := uiConfig.GetStyle(config.STYLE_MSGLIST_GUTTER) + pillStyle := uiConfig.GetStyle(config.STYLE_MSGLIST_PILL) + + // gutter + ctx.Fill(0, 0, 1, ctx.Height(), ' ', gutterStyle) + + // pill + pillSize := int(math.Ceil(float64(ctx.Height()) * ps.PercentVisible())) + pillOffset := int(math.Floor(float64(ctx.Height()) * ps.PercentScrolled())) + ctx.Fill(0, pillOffset, 1, pillSize, ' ', pillStyle) +} + +func (ps *PartSwitcher) MouseEvent(localX int, localY int, event vaxis.Event) { + if localY < ps.offset && ps.parts[ps.selected].term != nil { + ps.parts[ps.selected].term.MouseEvent(localX, localY, event) + return + } + + e, ok := event.(vaxis.Mouse) + if !ok { + return + } + + if ps.parts[ps.selected].term != nil { + ps.parts[ps.selected].term.Focus(false) + } + + switch e.Button { + case vaxis.MouseLeftButton: + i := localY - ps.offset + ps.Scroll() + if i < 0 || i >= len(ps.parts) { + break + } + if ps.parts[i].part.MIMEType == "multipart" { + break + } + ps.selected = i + ps.Invalidate() + case vaxis.MouseWheelDown: + ps.NextPart() + ps.Invalidate() + case vaxis.MouseWheelUp: + ps.PreviousPart() + ps.Invalidate() + } + + if ps.parts[ps.selected].term != nil { + ps.parts[ps.selected].term.Focus(true) + } +} + +func (ps *PartSwitcher) Cleanup() { + for _, partViewer := range ps.parts { + partViewer.Cleanup() + } +} diff --git a/app/pgpinfo.go b/app/pgpinfo.go new file mode 100644 index 0000000..0b2a482 --- /dev/null +++ b/app/pgpinfo.go @@ -0,0 +1,98 @@ +package app + +import ( + "fmt" + "strings" + "unicode/utf8" + + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/lib/ui" + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rockorager/vaxis" +) + +type PGPInfo struct { + details *models.MessageDetails + uiConfig *config.UIConfig +} + +func NewPGPInfo(details *models.MessageDetails, uiConfig *config.UIConfig) *PGPInfo { + return &PGPInfo{details: details, uiConfig: uiConfig} +} + +func (p *PGPInfo) DrawSignature(ctx *ui.Context) { + errorStyle := p.uiConfig.GetStyle(config.STYLE_ERROR) + warningStyle := p.uiConfig.GetStyle(config.STYLE_WARNING) + validStyle := p.uiConfig.GetStyle(config.STYLE_SUCCESS) + defaultStyle := p.uiConfig.GetStyle(config.STYLE_DEFAULT) + + var icon string + var indicatorStyle, textstyle vaxis.Style + textstyle = defaultStyle + var indicatorText, messageText string + // TODO: Nicer prompt for TOFU, fetch from keyserver, etc + switch p.details.SignatureValidity { + case models.UnknownEntity: + icon = p.uiConfig.IconUnknown + indicatorStyle = warningStyle + indicatorText = "Unknown" + messageText = fmt.Sprintf("Signed with unknown key (%8X); authenticity unknown", p.details.SignedByKeyId) + case models.Valid: + icon = p.uiConfig.IconSigned + if p.details.IsEncrypted && p.uiConfig.IconSignedEncrypted != "" { + icon = p.uiConfig.IconSignedEncrypted + } + indicatorStyle = validStyle + indicatorText = "Authentic" + messageText = fmt.Sprintf("Signature from %s (%8X)", p.details.SignedBy, p.details.SignedByKeyId) + default: + icon = p.uiConfig.IconInvalid + indicatorStyle = errorStyle + indicatorText = "Invalid signature!" + messageText = fmt.Sprintf("This message may have been tampered with! (%s)", p.details.SignatureError) + } + + x := ctx.Printf(0, 0, indicatorStyle, "%s %s ", icon, indicatorText) + ctx.Printf(x, 0, textstyle, "%s", messageText) +} + +func (p *PGPInfo) DrawEncryption(ctx *ui.Context, y int) { + warningStyle := p.uiConfig.GetStyle(config.STYLE_WARNING) + validStyle := p.uiConfig.GetStyle(config.STYLE_SUCCESS) + defaultStyle := p.uiConfig.GetStyle(config.STYLE_DEFAULT) + + // if a sign-encrypt combination icon is set, use that + icon := p.uiConfig.IconEncrypted + if p.details.IsSigned && p.details.SignatureValidity == models.Valid && p.uiConfig.IconSignedEncrypted != "" { + icon = strings.Repeat(" ", utf8.RuneCountInString(p.uiConfig.IconSignedEncrypted)) + } + + x := ctx.Printf(0, y, validStyle, "%s Encrypted", icon) + x += ctx.Printf(x+1, y, defaultStyle, "To %s (%8X) ", p.details.DecryptedWith, p.details.DecryptedWithKeyId) + if !p.details.IsSigned { + ctx.Printf(x, y, warningStyle, "(message not signed!)") + } +} + +func (p *PGPInfo) Draw(ctx *ui.Context) { + warningStyle := p.uiConfig.GetStyle(config.STYLE_WARNING) + defaultStyle := p.uiConfig.GetStyle(config.STYLE_DEFAULT) + ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', defaultStyle) + + switch { + case p.details == nil && p.uiConfig.IconUnencrypted != "": + x := ctx.Printf(0, 0, warningStyle, "%s ", p.uiConfig.IconUnencrypted) + ctx.Printf(x, 0, defaultStyle, "message unencrypted and unsigned") + case p.details.IsSigned && p.details.IsEncrypted: + p.DrawSignature(ctx) + p.DrawEncryption(ctx, 1) + case p.details.IsSigned: + p.DrawSignature(ctx) + case p.details.IsEncrypted: + p.DrawEncryption(ctx, 0) + } +} + +func (p *PGPInfo) Invalidate() { + ui.Invalidate() +} diff --git a/app/providesmessage.go b/app/providesmessage.go new file mode 100644 index 0000000..4572f65 --- /dev/null +++ b/app/providesmessage.go @@ -0,0 +1,30 @@ +package app + +import ( + "git.sr.ht/~rjarry/aerc/lib" + "git.sr.ht/~rjarry/aerc/lib/ui" + "git.sr.ht/~rjarry/aerc/models" +) + +type PartInfo struct { + Index []int + Msg *models.MessageInfo + Part *models.BodyStructure + Links []string +} + +type ProvidesMessage interface { + ui.Drawable + Store() *lib.MessageStore + SelectedAccount() *AccountView + SelectedMessage() (*models.MessageInfo, error) + SelectedMessagePart() *PartInfo +} + +type ProvidesMessages interface { + ui.Drawable + Store() *lib.MessageStore + SelectedAccount() *AccountView + SelectedMessage() (*models.MessageInfo, error) + MarkedMessages() ([]models.UID, error) +} diff --git a/app/quake.go b/app/quake.go new file mode 100644 index 0000000..9ceb645 --- /dev/null +++ b/app/quake.go @@ -0,0 +1,243 @@ +package app + +import ( + "os/exec" + "sync" + "sync/atomic" + "time" + + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/ui" + "git.sr.ht/~rockorager/vaxis" + "github.com/riywo/loginshell" +) + +var qt quakeTerminal + +type quakeTerminal struct { + mu sync.Mutex + rolling int32 + visible bool + term *Terminal +} + +func ToggleQuake() { + handleErr := func(err error) { + log.Errorf("quake-terminal: %v", err) + } + if !qt.HasTerm() { + shell, err := loginshell.Shell() + if err != nil { + handleErr(err) + return + } + args := []string{shell} + cmd := exec.Command(args[0], args[1:]...) + term, err := NewTerminal(cmd) + if err != nil { + handleErr(err) + return + } + term.OnClose = func(err error) { + if err != nil { + aerc.PushError(err.Error()) + } + qt.Hide() + qt.SetTerm(nil) + } + qt.SetTerm(term) + } + + if qt.Rolling() { + return + } + + if qt.Visible() { + qt.Hide() + } else { + qt.Show() + } +} + +func (q *quakeTerminal) Rolling() bool { + return atomic.LoadInt32(&q.rolling) > 0 +} + +func (q *quakeTerminal) SetTerm(t *Terminal) { + q.mu.Lock() + defer q.mu.Unlock() + + q.term = t +} + +func (q *quakeTerminal) HasTerm() bool { + q.mu.Lock() + defer q.mu.Unlock() + + return q.term != nil +} + +func (q *quakeTerminal) Visible() bool { + q.mu.Lock() + defer q.mu.Unlock() + + return q.visible +} + +// inputReturn is helper function to create dialog boxes. +func inputReturn() func(int) int { + return func(x int) int { return x } +} + +// fixReturn is helper function to create dialog boxes. +func fixReturn(x int) func(int) int { + return func(_ int) int { return x } +} + +func (q *quakeTerminal) Show() { + q.mu.Lock() + defer q.mu.Unlock() + + if q.term == nil { + return + } + + uiConfig := SelectedAccountUiConfig() + h := uiConfig.QuakeHeight + + termBox := NewDialog( + ui.NewBox(q.term, "", "", uiConfig), + fixReturn(0), + fixReturn(0), + inputReturn(), + fixReturn(h), + ) + + f := Roller{ + span: 100 * time.Millisecond, + done: func() { + log.Tracef("restore after show") + atomic.StoreInt32(&q.rolling, 0) + ui.QueueFunc(func() { + CloseDialog() + AddDialog(termBox) + }) + }, + } + + atomic.StoreInt32(&q.rolling, 1) + emptyBox := NewDialog( + ui.NewBox(&EmptyInteractive{}, "", "", uiConfig), + fixReturn(0), + fixReturn(0), + inputReturn(), + f.Roll(1, h), + ) + + q.visible = true + if q.term != nil { + q.term.Show(q.visible) + q.term.Focus(q.visible) + } + + CloseDialog() + AddDialog(emptyBox) +} + +func (q *quakeTerminal) Hide() { + uiConfig := SelectedAccountUiConfig() + f := Roller{ + span: 100 * time.Millisecond, + done: func() { + atomic.StoreInt32(&q.rolling, 0) + ui.QueueFunc(CloseDialog) + log.Tracef("restore after hide") + }, + } + + atomic.StoreInt32(&q.rolling, 1) + emptyBox := NewDialog( + ui.NewBox(&EmptyInteractive{}, "", "", uiConfig), + fixReturn(0), + fixReturn(0), + inputReturn(), + f.Roll(uiConfig.QuakeHeight, 2), + ) + + q.mu.Lock() + q.visible = false + if q.term != nil { + q.term.Focus(q.visible) + q.term.Show(q.visible) + } + q.mu.Unlock() + + ui.QueueFunc(func() { + CloseDialog() + AddDialog(emptyBox) + }) +} + +type EmptyInteractive struct{} + +func (e *EmptyInteractive) Draw(ctx *ui.Context) { + w := ctx.Width() + h := ctx.Height() + if w == 0 || h == 0 { + return + } + style := SelectedAccountUiConfig().GetStyle(config.STYLE_DEFAULT) + ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', style) +} + +func (e *EmptyInteractive) Invalidate() { +} + +func (e *EmptyInteractive) MouseEvent(_ int, _ int, _ vaxis.Event) { +} + +func (e *EmptyInteractive) Event(_ vaxis.Event) bool { + return true +} + +func (e *EmptyInteractive) Focus(_ bool) { +} + +type Roller struct { + span time.Duration + done func() + value int64 +} + +func (f *Roller) Roll(start, end int) func(int) int { + nsteps := end - start + + var step int64 = 1 + if end < start { + step = -1 + nsteps = -nsteps + } + + span := f.span.Milliseconds() / int64(nsteps) + refresh := time.Duration(span) * time.Millisecond + + atomic.StoreInt64(&f.value, int64(start)) + + go func() { + defer log.PanicHandler() + for i := 0; i < int(nsteps); i++ { + aerc.Invalidate() + time.Sleep(refresh) + atomic.AddInt64(&f.value, step) + } + if f.done != nil { + ui.QueueFunc(f.done) + } + }() + + return func(_ int) int { + log.Tracef("in roller") + return int(atomic.LoadInt64(&f.value)) + } +} diff --git a/app/scrollable.go b/app/scrollable.go new file mode 100644 index 0000000..0443637 --- /dev/null +++ b/app/scrollable.go @@ -0,0 +1,101 @@ +package app + +// Scrollable implements vertical scrolling +type Scrollable struct { + scroll int + offset int + height int + elems int +} + +func (s *Scrollable) Scroll() int { + return s.scroll +} + +func (s *Scrollable) SetOffset(offset int) { + s.offset = offset +} + +func (s *Scrollable) ScrollOffset() int { + return s.offset +} + +func (s *Scrollable) PercentVisible() float64 { + if s.elems <= 0 { + return 1.0 + } + return float64(s.height) / float64(s.elems) +} + +func (s *Scrollable) PercentScrolled() float64 { + if s.elems <= 0 { + return 1.0 + } + return float64(s.scroll) / float64(s.elems) +} + +func (s *Scrollable) NeedScrollbar() bool { + needScrollbar := true + if s.PercentVisible() >= 1.0 { + needScrollbar = false + } + return needScrollbar +} + +func (s *Scrollable) UpdateScroller(height, elems int) { + s.height = height + s.elems = elems +} + +func (s *Scrollable) EnsureScroll(idx int) { + if idx < 0 { + return + } + + middle := s.height / 2 + switch { + case s.offset > middle: + s.scroll = idx - middle + case idx < s.scroll+s.offset: + s.scroll = idx - s.offset + case idx >= s.scroll-s.offset+s.height: + s.scroll = idx + s.offset - s.height + 1 + } + + s.checkBounds() +} + +func (s *Scrollable) checkBounds() { + maxScroll := s.elems - s.height + if maxScroll < 0 { + maxScroll = 0 + } + + if s.scroll > maxScroll { + s.scroll = maxScroll + } + + if s.scroll < 0 { + s.scroll = 0 + } +} + +type AlignPosition uint + +const ( + AlignTop AlignPosition = iota + AlignCenter + AlignBottom +) + +func (s *Scrollable) Align(idx int, pos AlignPosition) { + switch pos { + case AlignTop: + s.scroll = idx + case AlignCenter: + s.scroll = idx - s.height/2 + case AlignBottom: + s.scroll = idx - s.height + 1 + } + s.checkBounds() +} diff --git a/app/selector.go b/app/selector.go new file mode 100644 index 0000000..705021f --- /dev/null +++ b/app/selector.go @@ -0,0 +1,275 @@ +package app + +import ( + "fmt" + "strings" + + "github.com/mattn/go-runewidth" + + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/lib/ui" + "git.sr.ht/~rockorager/vaxis" +) + +type Selector struct { + chooser bool + focused bool + focus int + options []string + uiConfig *config.UIConfig + + onChoose func(option string) + onSelect func(option string) +} + +func NewSelector(options []string, focus int, uiConfig *config.UIConfig) *Selector { + return &Selector{ + focus: focus, + options: options, + uiConfig: uiConfig, + } +} + +func (sel *Selector) Chooser(chooser bool) *Selector { + sel.chooser = chooser + return sel +} + +func (sel *Selector) Invalidate() { + ui.Invalidate() +} + +func (sel *Selector) Draw(ctx *ui.Context) { + defaultSelectorStyle := sel.uiConfig.GetStyle(config.STYLE_SELECTOR_DEFAULT) + w, h := ctx.Width(), ctx.Height() + ctx.Fill(0, 0, w, h, ' ', defaultSelectorStyle) + + if w < 5 || h < 1 { + // if width and height are that small, don't even try to draw + // something + return + } + + y := 1 + if h == 1 { + y = 0 + } + + format := "[%s]" + + calculateWidth := func(space int) int { + neededWidth := 2 + for i, option := range sel.options { + neededWidth += runewidth.StringWidth(fmt.Sprintf(format, option)) + if i < len(sel.options)-1 { + neededWidth += space + } + } + return neededWidth - space + } + + space := 5 + for ; space > 0; space-- { + if w > calculateWidth(space) { + break + } + } + + x := 2 + for i, option := range sel.options { + style := defaultSelectorStyle + if sel.focus == i { + if sel.focused { + style = sel.uiConfig.GetStyle(config.STYLE_SELECTOR_FOCUSED) + } else if sel.chooser { + style = sel.uiConfig.GetStyle(config.STYLE_SELECTOR_CHOOSER) + } + } + + if space == 0 { + if sel.focus == i { + leftArrow, rightArrow := ' ', ' ' + if i > 0 { + leftArrow = '❮' + } + if i < len(sel.options)-1 { + rightArrow = '❯' + } + + s := runewidth.Truncate(option, + w-runewidth.RuneWidth(leftArrow)-runewidth.RuneWidth(rightArrow)-runewidth.StringWidth(fmt.Sprintf(format, "")), + "…") + + nextPos := 0 + nextPos += ctx.Printf(nextPos, y, defaultSelectorStyle, "%c", leftArrow) + nextPos += ctx.Printf(nextPos, y, style, format, s) + ctx.Printf(nextPos, y, defaultSelectorStyle, "%c", rightArrow) + } + } else { + x += ctx.Printf(x, y, style, format, option) + x += space + } + } +} + +func (sel *Selector) OnChoose(fn func(option string)) *Selector { + sel.onChoose = fn + return sel +} + +func (sel *Selector) OnSelect(fn func(option string)) *Selector { + sel.onSelect = fn + return sel +} + +func (sel *Selector) Select(option string) { + for i, opt := range sel.options { + if option == opt { + sel.focus = i + if sel.onSelect != nil { + sel.onSelect(opt) + } + break + } + } +} + +func (sel *Selector) Selected() string { + return sel.options[sel.focus] +} + +func (sel *Selector) Focus(focus bool) { + sel.focused = focus + sel.Invalidate() +} + +func (sel *Selector) Event(event vaxis.Event) bool { + if key, ok := event.(vaxis.Key); ok { + switch { + case key.Matches('h', vaxis.ModCtrl): + fallthrough + case key.Matches(vaxis.KeyLeft): + if sel.focus > 0 { + sel.focus-- + sel.Invalidate() + } + if sel.onSelect != nil { + sel.onSelect(sel.Selected()) + } + case key.Matches('l', vaxis.ModCtrl): + fallthrough + case key.Matches(vaxis.KeyRight): + if sel.focus < len(sel.options)-1 { + sel.focus++ + sel.Invalidate() + } + if sel.onSelect != nil { + sel.onSelect(sel.Selected()) + } + case key.Matches(vaxis.KeyEnter): + if sel.onChoose != nil { + sel.onChoose(sel.Selected()) + } + } + } + return false +} + +var ErrNoOptionSelected = fmt.Errorf("no option selected") + +type SelectorDialog struct { + callback func(string, error) + title string + prompt string + uiConfig *config.UIConfig + selector *Selector +} + +func NewSelectorDialog(title string, prompt string, options []string, focus int, + uiConfig *config.UIConfig, cb func(string, error), +) *SelectorDialog { + sd := &SelectorDialog{ + callback: cb, + title: title, + prompt: strings.TrimSpace(prompt), + uiConfig: uiConfig, + selector: NewSelector(options, focus, uiConfig).Chooser(true), + } + sd.selector.Focus(true) + return sd +} + +func (gp *SelectorDialog) Draw(ctx *ui.Context) { + defaultStyle := gp.uiConfig.GetStyle(config.STYLE_DEFAULT) + titleStyle := gp.uiConfig.GetStyle(config.STYLE_TITLE) + + ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', defaultStyle) + ctx.Fill(0, 0, ctx.Width(), 1, ' ', titleStyle) + ctx.Printf(1, 0, titleStyle, "%s", gp.title) + var i int + lines := strings.Split(gp.prompt, "\n") + for i = 0; i < len(lines); i++ { + ctx.Printf(1, 2+i, defaultStyle, "%s", lines[i]) + } + gp.selector.Draw(ctx.Subcontext(1, ctx.Height()-1, ctx.Width()-2, 1)) +} + +func (gp *SelectorDialog) ContextWidth() (func(int) int, func(int) int) { + // horizontal starting position in columns from the left + start := func(int) int { + return 4 + } + // dialog width from the starting column + width := func(w int) int { + return w - 8 + } + return start, width +} + +func (gp *SelectorDialog) ContextHeight() (func(int) int, func(int) int) { + totalHeight := 2 // title + empty line + totalHeight += strings.Count(gp.prompt, "\n") + 1 + totalHeight += 2 // empty line + selector + start := func(h int) int { + s := h/2 - totalHeight/2 + if s < 0 { + s = 0 + } + return s + } + height := func(h int) int { + if totalHeight > h { + return h + } else { + return totalHeight + } + } + return start, height +} + +func (gp *SelectorDialog) Invalidate() { + ui.Invalidate() +} + +func (gp *SelectorDialog) Event(event vaxis.Event) bool { + switch event := event.(type) { + case vaxis.Key: + switch { + case event.Matches(vaxis.KeyEnter): + gp.selector.Focus(false) + gp.callback(gp.selector.Selected(), nil) + case event.Matches(vaxis.KeyEsc): + gp.selector.Focus(false) + gp.callback("", ErrNoOptionSelected) + default: + gp.selector.Event(event) + } + default: + gp.selector.Event(event) + } + return true +} + +func (gp *SelectorDialog) Focus(f bool) { + gp.selector.Focus(f) +} diff --git a/app/spinner.go b/app/spinner.go new file mode 100644 index 0000000..28cee9b --- /dev/null +++ b/app/spinner.go @@ -0,0 +1,85 @@ +package app + +import ( + "strings" + "sync/atomic" + "time" + + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/ui" + "git.sr.ht/~rockorager/vaxis" +) + +type Spinner struct { + frame int64 // access via atomic + frames []string + interval time.Duration + stop chan struct{} + style vaxis.Style +} + +func NewSpinner(uiConf *config.UIConfig) *Spinner { + spinner := Spinner{ + stop: make(chan struct{}), + frame: -1, + interval: uiConf.SpinnerInterval, + frames: strings.Split(uiConf.Spinner, uiConf.SpinnerDelimiter), + style: uiConf.GetStyle(config.STYLE_SPINNER), + } + return &spinner +} + +func (s *Spinner) Start() { + if s.IsRunning() { + return + } + + atomic.StoreInt64(&s.frame, 0) + + go func() { + defer log.PanicHandler() + + for { + select { + case <-s.stop: + atomic.StoreInt64(&s.frame, -1) + s.stop <- struct{}{} + return + case <-time.After(s.interval): + atomic.AddInt64(&s.frame, 1) + ui.Invalidate() + } + } + }() +} + +func (s *Spinner) Stop() { + if !s.IsRunning() { + return + } + + s.stop <- struct{}{} + <-s.stop + s.Invalidate() +} + +func (s *Spinner) IsRunning() bool { + return atomic.LoadInt64(&s.frame) != -1 +} + +func (s *Spinner) Draw(ctx *ui.Context) { + if !s.IsRunning() { + s.Start() + } + + cur := int(atomic.LoadInt64(&s.frame) % int64(len(s.frames))) + + ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', s.style) + col := ctx.Width()/2 - len(s.frames[0])/2 + 1 + ctx.Printf(col, 0, s.style, "%s", s.frames[cur]) +} + +func (s *Spinner) Invalidate() { + ui.Invalidate() +} diff --git a/app/status.go b/app/status.go new file mode 100644 index 0000000..846e0e4 --- /dev/null +++ b/app/status.go @@ -0,0 +1,165 @@ +package app + +import ( + "bytes" + "sync" + "time" + + "github.com/mattn/go-runewidth" + + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/state" + "git.sr.ht/~rjarry/aerc/lib/templates" + "git.sr.ht/~rjarry/aerc/lib/ui" + "git.sr.ht/~rockorager/vaxis" +) + +type StatusLine struct { + sync.Mutex + stack []*StatusMessage + acct *AccountView + err string +} + +type StatusMessage struct { + style vaxis.Style + message string +} + +func (status *StatusLine) Invalidate() { + ui.Invalidate() +} + +func (status *StatusLine) Draw(ctx *ui.Context) { + status.Lock() + defer status.Unlock() + style := status.uiConfig().GetStyle(config.STYLE_STATUSLINE_DEFAULT) + ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', style) + switch { + case len(status.stack) != 0: + line := status.stack[len(status.stack)-1] + msg := runewidth.Truncate(line.message, ctx.Width(), "") + msg = runewidth.FillRight(msg, ctx.Width()) + ctx.Printf(0, 0, line.style, "%s", msg) + case status.err != "": + msg := runewidth.Truncate(status.err, ctx.Width(), "") + msg = runewidth.FillRight(msg, ctx.Width()) + style := status.uiConfig().GetStyle(config.STYLE_STATUSLINE_ERROR) + ctx.Printf(0, 0, style, "%s", msg) + case status.acct != nil: + data := state.NewDataSetter() + data.SetPendingKeys(aerc.pendingKeys) + data.SetState(&status.acct.state) + data.SetAccount(status.acct.acct) + data.SetFolder(status.acct.Directories().SelectedDirectory()) + msg, _ := status.acct.SelectedMessage() + data.SetInfo(msg, 0, false) + data.SetRUE(status.acct.dirlist.List(), status.acct.dirlist.GetRUECount) + if store := status.acct.Store(); store != nil { + data.SetVisual(store.Marker().IsVisualMark()) + } + table := ui.NewTable( + ctx.Height(), + config.Statusline.StatusColumns, + config.Statusline.ColumnSeparator, + nil, + func(*ui.Table, int) vaxis.Style { return style }, + ) + var buf bytes.Buffer + cells := make([]string, len(table.Columns)) + for c, col := range table.Columns { + err := templates.Render(col.Def.Template, &buf, + data.Data()) + if err != nil { + log.Errorf("%s", err) + cells[c] = err.Error() + } else { + cells[c] = buf.String() + } + buf.Reset() + } + table.AddRow(cells, nil) + table.Draw(ctx) + } +} + +func (status *StatusLine) Update(acct *AccountView) { + status.acct = acct + status.Invalidate() +} + +func (status *StatusLine) SetError(err string) { + prev := status.err + status.err = err + if prev != status.err { + status.Invalidate() + } +} + +func (status *StatusLine) Clear() { + status.SetError("") + status.acct = nil +} + +func (status *StatusLine) Push(text string, expiry time.Duration) *StatusMessage { + status.Lock() + defer status.Unlock() + log.Debugf(text) + msg := &StatusMessage{ + style: status.uiConfig().GetStyle(config.STYLE_STATUSLINE_DEFAULT), + message: text, + } + status.stack = append(status.stack, msg) + go (func() { + defer log.PanicHandler() + + time.Sleep(expiry) + status.Lock() + defer status.Unlock() + for i, m := range status.stack { + if m == msg { + status.stack = append(status.stack[:i], status.stack[i+1:]...) + break + } + } + status.Invalidate() + })() + status.Invalidate() + return msg +} + +func (status *StatusLine) PushError(text string) *StatusMessage { + log.Errorf(text) + msg := status.Push(text, 10*time.Second) + msg.Color(status.uiConfig().GetStyle(config.STYLE_STATUSLINE_ERROR)) + return msg +} + +func (status *StatusLine) PushWarning(text string) *StatusMessage { + log.Warnf(text) + msg := status.Push(text, 10*time.Second) + msg.Color(status.uiConfig().GetStyle(config.STYLE_STATUSLINE_WARNING)) + return msg +} + +func (status *StatusLine) PushSuccess(text string) *StatusMessage { + log.Tracef(text) + msg := status.Push(text, 10*time.Second) + msg.Color(status.uiConfig().GetStyle(config.STYLE_STATUSLINE_SUCCESS)) + return msg +} + +func (status *StatusLine) Expire() { + status.Lock() + defer status.Unlock() + status.stack = nil +} + +func (status *StatusLine) uiConfig() *config.UIConfig { + return SelectedAccountUiConfig() +} + +func (msg *StatusMessage) Color(style vaxis.Style) { + msg.style = style +} diff --git a/app/terminal.go b/app/terminal.go new file mode 100644 index 0000000..9bf4f86 --- /dev/null +++ b/app/terminal.go @@ -0,0 +1,177 @@ +package app + +import ( + "os/exec" + "sync/atomic" + + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/ui" + "git.sr.ht/~rockorager/vaxis" + "git.sr.ht/~rockorager/vaxis/widgets/term" +) + +type HasTerminal interface { + Terminal() *Terminal +} + +type Terminal struct { + closed int32 + visible int32 // visible if >0 + cmd *exec.Cmd + ctx *ui.Context + focus bool + vterm *term.Model + running bool + + OnClose func(err error) + OnEvent func(event vaxis.Event) bool + OnStart func() + OnTitle func(title string) +} + +func NewTerminal(cmd *exec.Cmd) (*Terminal, error) { + term := &Terminal{ + cmd: cmd, + vterm: term.New(), + visible: 1, + } + term.vterm.OSC8 = config.General.EnableOSC8 + term.vterm.TERM = config.General.Term + return term, nil +} + +func (term *Terminal) Close() { + term.closeErr(nil) +} + +// TODO: replace with atomic.Bool when min go version will have it (1.19+) +const closed int32 = 1 + +func (term *Terminal) isClosed() bool { + return atomic.LoadInt32(&term.closed) == closed +} + +func (term *Terminal) closeErr(err error) { + if atomic.SwapInt32(&term.closed, closed) == closed { + return + } + if term.vterm != nil { + // Stop receiving events + term.vterm.Detach() + term.vterm.Close() + if term.ctx != nil { + term.ctx.HideCursor() + } + } + if term.OnClose != nil { + term.OnClose(err) + } + ui.Invalidate() +} + +func (term *Terminal) Destroy() { + // If we destroy, we don't want to call the OnClose callback + term.OnClose = nil + term.closeErr(nil) +} + +func (term *Terminal) Invalidate() { + ui.Invalidate() +} + +func (term *Terminal) Draw(ctx *ui.Context) { + if ctx.Width() == 0 || ctx.Height() == 0 { + return + } + term.ctx = ctx + if !term.running && term.cmd != nil { + term.vterm.Attach(term.HandleEvent) + w, h := ctx.Window().Size() + if err := term.vterm.StartWithSize(term.cmd, w, h); err != nil { + log.Errorf("error running terminal: %v", err) + term.closeErr(err) + return + } + term.running = true + if term.OnStart != nil { + term.OnStart() + } + } + term.vterm.Draw(ctx.Window()) +} + +func (term *Terminal) Show(visible bool) { + if visible { + atomic.StoreInt32(&term.visible, 1) + } else { + atomic.StoreInt32(&term.visible, 0) + } +} + +func (term *Terminal) Terminal() *Terminal { + return term +} + +func (term *Terminal) MouseEvent(localX int, localY int, event vaxis.Event) { + ev, ok := event.(vaxis.Mouse) + if !ok { + return + } + if term.OnEvent != nil { + term.OnEvent(ev) + } + if term.isClosed() { + return + } + ev.Row = localY + ev.Col = localX + term.vterm.Update(ev) +} + +func (term *Terminal) Focus(focus bool) { + if term.isClosed() { + return + } + term.focus = focus + if term.focus { + term.vterm.Focus() + } else { + term.vterm.Blur() + } +} + +// HandleEvent is used to watch the underlying terminal events +func (t *Terminal) HandleEvent(ev vaxis.Event) { + if t.isClosed() { + return + } + switch ev := ev.(type) { + case vaxis.Redraw: + if atomic.LoadInt32(&t.visible) > 0 { + ui.Invalidate() + } + case term.EventTitle: + if t.OnTitle != nil { + t.OnTitle(string(ev)) + } + case term.EventClosed: + t.Close() + ui.Invalidate() + case term.EventBell: + aerc.Beep() + } +} + +func (term *Terminal) Event(event vaxis.Event) bool { + if term.OnEvent != nil { + if term.OnEvent(event) { + return true + } + } + if term.isClosed() { + return false + } + term.vterm.Update(event) + return true +} diff --git a/commands/account/align.go b/commands/account/align.go new file mode 100644 index 0000000..2c65b55 --- /dev/null +++ b/commands/account/align.go @@ -0,0 +1,64 @@ +package account + +import ( + "errors" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/lib/ui" +) + +type Align struct { + Pos app.AlignPosition `opt:"pos" metavar:"top|center|bottom" action:"ParsePos" complete:"CompletePos" desc:"Position."` +} + +func init() { + commands.Register(Align{}) +} + +func (Align) Description() string { + return "Align the message list view." +} + +var posNames []string = []string{"top", "center", "bottom"} + +func (a *Align) ParsePos(arg string) error { + switch arg { + case "top": + a.Pos = app.AlignTop + case "center": + a.Pos = app.AlignCenter + case "bottom": + a.Pos = app.AlignBottom + default: + return errors.New("invalid alignment") + } + return nil +} + +func (a *Align) CompletePos(arg string) []string { + return commands.FilterList(posNames, arg, commands.QuoteSpace) +} + +func (Align) Context() commands.CommandContext { + return commands.MESSAGE_LIST +} + +func (Align) Aliases() []string { + return []string{"align"} +} + +func (a Align) Execute(args []string) error { + acct := app.SelectedAccount() + if acct == nil { + return errors.New("no account selected") + } + msgList := acct.Messages() + if msgList == nil { + return errors.New("no message list available") + } + msgList.AlignMessage(a.Pos) + ui.Invalidate() + + return nil +} diff --git a/commands/account/cf.go b/commands/account/cf.go new file mode 100644 index 0000000..8ec327a --- /dev/null +++ b/commands/account/cf.go @@ -0,0 +1,143 @@ +package account + +import ( + "errors" + "strings" + "time" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/lib/state" + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/types" + "git.sr.ht/~rjarry/go-opt/v2" +) + +type ChangeFolder struct { + Account string `opt:"-a" complete:"CompleteAccount" desc:"Change to specified account."` + Folder string `opt:"..." complete:"CompleteFolderAndNotmuch" desc:"Folder name."` +} + +func init() { + commands.Register(ChangeFolder{}) +} + +func (ChangeFolder) Description() string { + return "Change the folder shown in the message list." +} + +func (ChangeFolder) Context() commands.CommandContext { + return commands.MESSAGE_LIST +} + +func (ChangeFolder) Aliases() []string { + return []string{"cf"} +} + +func (c *ChangeFolder) CompleteAccount(arg string) []string { + return commands.FilterList(app.AccountNames(), arg, commands.QuoteSpace) +} + +func (c *ChangeFolder) CompleteFolderAndNotmuch(arg string) []string { + acct := app.SelectedAccount() + if acct == nil { + return nil + } + retval := commands.FilterList( + acct.Directories().List(), arg, + func(s string) string { + dir := acct.Directories().Directory(s) + if dir != nil && dir.Role != models.QueryRole { + s = opt.QuoteArg(s) + } + return s + }, + ) + if acct.AccountConfig().Backend == "notmuch" { + notmuchcomps := handleNotmuchComplete(arg) + for _, prefix := range notmuch_search_terms { + if strings.HasPrefix(arg, prefix) { + return notmuchcomps + } + } + retval = append(retval, notmuchcomps...) + + } + return retval +} + +func (c ChangeFolder) Execute([]string) error { + var target string + var acct *app.AccountView + var err error + + args := opt.LexArgs(c.Folder) + + if c.Account != "" { + acct, err = app.Account(c.Account) + if err != nil { + return err + } + } else { + acct = app.SelectedAccount() + if acct == nil { + return errors.New("No account selected") + } + } + + if args.Count() == 0 { + return errors.New("<folder> is required. Usage: cf [-a <account>] <folder>") + } + + if acct.AccountConfig().Backend == "notmuch" { + // With notmuch, :cf can change to a "dynamic folder" that + // contains the result of a query. Preserve the entered + // arguments verbatim. + target = args.String() + } else { + if args.Count() != 1 { + return errors.New("Unexpected argument(s). Usage: cf [-a <account>] <folder>") + } + target = args.Arg(0) + } + + finalize := func(msg types.WorkerMessage) { + handleDirOpenResponse(acct, msg) + } + + dirlist := acct.Directories() + if dirlist == nil { + return errors.New("No directory list found") + } + + if target == "-" { + dir := dirlist.Previous() + if dir != "" { + target = dir + } else { + return errors.New("No previous folder to return to") + } + } + + dirlist.Open(target, "", 0*time.Second, finalize, false) + + return nil +} + +func handleDirOpenResponse(acct *app.AccountView, msg types.WorkerMessage) { + // As we're waiting for the worker to report status we must run + // the rest of the actions in this callback. + switch msg := msg.(type) { + case *types.Error: + app.PushError(msg.Error.Error()) + case *types.Done: + // reset store filtering if we switched folders + store := acct.Store() + if store != nil { + store.ApplyClear() + acct.SetStatus(state.SearchFilterClear()) + } + // focus account tab + acct.Select() + } +} diff --git a/commands/account/check-mail.go b/commands/account/check-mail.go new file mode 100644 index 0000000..507fdc3 --- /dev/null +++ b/commands/account/check-mail.go @@ -0,0 +1,36 @@ +package account + +import ( + "errors" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" +) + +type CheckMail struct{} + +func init() { + commands.Register(CheckMail{}) +} + +func (CheckMail) Description() string { + return "Check for new mail on the selected account." +} + +func (CheckMail) Context() commands.CommandContext { + return commands.MESSAGE_LIST +} + +func (CheckMail) Aliases() []string { + return []string{"check-mail"} +} + +func (CheckMail) Execute(args []string) error { + acct := app.SelectedAccount() + if acct == nil { + return errors.New("No account selected") + } + acct.CheckMailReset() + acct.CheckMail() + return nil +} diff --git a/commands/account/clear.go b/commands/account/clear.go new file mode 100644 index 0000000..d45c520 --- /dev/null +++ b/commands/account/clear.go @@ -0,0 +1,48 @@ +package account + +import ( + "errors" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/lib/state" +) + +type Clear struct { + Selected bool `opt:"-s" desc:"Select first message after clearing."` +} + +func init() { + commands.Register(Clear{}) +} + +func (Clear) Description() string { + return "Clear the current search or filter criteria." +} + +func (Clear) Context() commands.CommandContext { + return commands.MESSAGE_LIST +} + +func (Clear) Aliases() []string { + return []string{"clear"} +} + +func (c Clear) Execute(args []string) error { + acct := app.SelectedAccount() + if acct == nil { + return errors.New("No account selected") + } + store := acct.Store() + if store == nil { + return errors.New("Cannot perform action. Messages still loading") + } + + if c.Selected { + defer store.Select("") + } + store.ApplyClear() + acct.SetStatus(state.SearchFilterClear()) + + return nil +} diff --git a/commands/account/compose.go b/commands/account/compose.go new file mode 100644 index 0000000..ebac636 --- /dev/null +++ b/commands/account/compose.go @@ -0,0 +1,95 @@ +package account + +import ( + "errors" + "fmt" + "io" + gomail "net/mail" + "regexp" + "strings" + + "github.com/emersion/go-message/mail" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/config" +) + +type Compose struct { + Headers string `opt:"-H" action:"ParseHeader" desc:"Add the specified header to the message."` + Template string `opt:"-T" complete:"CompleteTemplate" desc:"Template name."` + Edit bool `opt:"-e" desc:"Force [compose].edit-headers = true."` + NoEdit bool `opt:"-E" desc:"Force [compose].edit-headers = false."` + SkipEditor bool `opt:"-s" desc:"Skip the editor and go directly to the review screen."` + Body string `opt:"..." required:"false"` +} + +func init() { + commands.Register(Compose{}) +} + +func (Compose) Description() string { + return "Open the compose window to write a new email." +} + +func (Compose) Context() commands.CommandContext { + return commands.MESSAGE_LIST +} + +func (c *Compose) ParseHeader(arg string) error { + if strings.Contains(arg, ":") { + // ensure first colon is followed by a single space + re := regexp.MustCompile(`^(.*?):\s*(.*)`) + c.Headers += re.ReplaceAllString(arg, "$1: $2\r\n") + } else { + c.Headers += arg + ":\r\n" + } + return nil +} + +func (*Compose) CompleteTemplate(arg string) []string { + return commands.GetTemplates(arg) +} + +func (Compose) Aliases() []string { + return []string{"compose"} +} + +func (c Compose) Execute(args []string) error { + if c.Headers != "" { + if c.Body != "" { + c.Body = c.Headers + "\r\n" + c.Body + } else { + c.Body = c.Headers + "\r\n\r\n" + } + } + if c.Template == "" { + c.Template = config.Templates.NewMessage + } + editHeaders := (config.Compose.EditHeaders || c.Edit) && !c.NoEdit + + acct := app.SelectedAccount() + if acct == nil { + return errors.New("No account selected") + } + + msg, err := gomail.ReadMessage(strings.NewReader(c.Body)) + if errors.Is(err, io.EOF) { // completely empty + msg = &gomail.Message{Body: strings.NewReader("")} + } else if err != nil { + return fmt.Errorf("mail.ReadMessage: %w", err) + } + headers := mail.HeaderFromMap(msg.Header) + + composer, err := app.NewComposer(acct, + acct.AccountConfig(), acct.Worker(), editHeaders, + c.Template, &headers, nil, msg.Body) + if err != nil { + return err + } + composer.Tab = app.NewTab(composer, "New email") + if c.SkipEditor { + composer.Terminal().Close() + } + return nil +} diff --git a/commands/account/connection.go b/commands/account/connection.go new file mode 100644 index 0000000..7ea1ef0 --- /dev/null +++ b/commands/account/connection.go @@ -0,0 +1,46 @@ +package account + +import ( + "errors" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/lib/state" + "git.sr.ht/~rjarry/aerc/worker/types" +) + +type Connection struct{} + +func init() { + commands.Register(Connection{}) +} + +func (Connection) Description() string { + return "Disconnect or reconnect the current account." +} + +func (Connection) Context() commands.CommandContext { + return commands.MESSAGE_LIST +} + +func (Connection) Aliases() []string { + return []string{"connect", "disconnect"} +} + +func (c Connection) Execute(args []string) error { + acct := app.SelectedAccount() + if acct == nil { + return errors.New("No account selected") + } + cb := func(msg types.WorkerMessage) { + acct.SetStatus(state.ConnectionActivity("")) + } + if args[0] == "connect" { + acct.Worker().PostAction(&types.Connect{}, cb) + acct.SetStatus(state.ConnectionActivity("Connecting...")) + } else { + acct.Worker().PostAction(&types.Disconnect{}, cb) + acct.SetStatus(state.ConnectionActivity("Disconnecting...")) + } + return nil +} diff --git a/commands/account/expand-folder.go b/commands/account/expand-folder.go new file mode 100644 index 0000000..0422c55 --- /dev/null +++ b/commands/account/expand-folder.go @@ -0,0 +1,52 @@ +package account + +import ( + "errors" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" +) + +type ExpandCollapseFolder struct { + Folder string `opt:"folder" required:"false" complete:"CompleteFolder" desc:"Folder name."` +} + +func init() { + commands.Register(ExpandCollapseFolder{}) +} + +func (ExpandCollapseFolder) Description() string { + return "Expand or collapse the current folder." +} + +func (ExpandCollapseFolder) Context() commands.CommandContext { + return commands.MESSAGE_LIST +} + +func (ExpandCollapseFolder) Aliases() []string { + return []string{"expand-folder", "collapse-folder"} +} + +func (*ExpandCollapseFolder) CompleteFolder(arg string) []string { + acct := app.SelectedAccount() + if acct == nil { + return nil + } + return commands.FilterList(acct.Directories().List(), arg, nil) +} + +func (e ExpandCollapseFolder) Execute(args []string) error { + acct := app.SelectedAccount() + if acct == nil { + return errors.New("No account selected") + } + if e.Folder == "" { + e.Folder = acct.Directories().Selected() + } + if args[0] == "expand-folder" { + acct.Directories().ExpandFolder(e.Folder) + } else { + acct.Directories().CollapseFolder(e.Folder) + } + return nil +} diff --git a/commands/account/export-mbox.go b/commands/account/export-mbox.go new file mode 100644 index 0000000..529dbae --- /dev/null +++ b/commands/account/export-mbox.go @@ -0,0 +1,198 @@ +package account + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/lib" + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/xdg" + "git.sr.ht/~rjarry/aerc/models" + mboxer "git.sr.ht/~rjarry/aerc/worker/mbox" + "git.sr.ht/~rjarry/aerc/worker/types" +) + +type ExportMbox struct { + Filename string `opt:"filename" complete:"CompleteFilename" desc:"Output file path."` +} + +func init() { + commands.Register(ExportMbox{}) +} + +func (ExportMbox) Description() string { + return "Export messages in the current folder to an mbox file." +} + +func (ExportMbox) Context() commands.CommandContext { + return commands.MESSAGE_LIST +} + +func (ExportMbox) Aliases() []string { + return []string{"export-mbox"} +} + +func (*ExportMbox) CompleteFilename(arg string) []string { + return commands.CompletePath(arg, false) +} + +func (e ExportMbox) Execute(args []string) error { + acct := app.SelectedAccount() + if acct == nil { + return errors.New("No account selected") + } + store := acct.Store() + if store == nil { + return errors.New("No message store selected") + } + + e.Filename = xdg.ExpandHome(e.Filename) + + fi, err := os.Stat(e.Filename) + if err == nil && fi.IsDir() { + if path := acct.SelectedDirectory(); path != "" { + if f := filepath.Base(path); f != "" { + e.Filename = filepath.Join(e.Filename, f+".mbox") + } + } + } + + app.PushStatus("Exporting to "+e.Filename, 10*time.Second) + + // uids of messages to export + var uids []models.UID + + // check if something is marked - we export that then + msgProvider, ok := app.SelectedTabContent().(app.ProvidesMessages) + if !ok { + msgProvider = app.SelectedAccount() + } + if msgProvider != nil { + marked, err := msgProvider.MarkedMessages() + if err == nil && len(marked) > 0 { + uids, err = sortMarkedUids(marked, store) + if err != nil { + return err + } + } + } + + // if no messages were marked, we export everything + if len(uids) == 0 { + var err error + uids, err = sortAllUids(store) + if err != nil { + return err + } + } + + go func() { + defer log.PanicHandler() + file, err := os.Create(e.Filename) + if err != nil { + log.Errorf("failed to create file: %v", err) + app.PushError(err.Error()) + return + } + defer file.Close() + + var mu sync.Mutex + var ctr uint + var retries int + + done := make(chan bool) + + t := time.Now() + total := len(uids) + + for len(uids) > 0 { + if retries > 0 { + if retries > 10 { + errorMsg := fmt.Sprintf("too many retries: %d; stopping export", retries) + log.Errorf(errorMsg) + app.PushError(args[0] + " " + errorMsg) + break + } + sleeping := time.Duration(retries * 1e9 * 2) + log.Debugf("sleeping for %s before retrying; retries: %d", sleeping, retries) + time.Sleep(sleeping) + } + + log.Debugf("fetching %d for export", len(uids)) + acct.Worker().PostAction(&types.FetchFullMessages{ + Uids: uids, + }, func(msg types.WorkerMessage) { + switch msg := msg.(type) { + case *types.Done: + done <- true + case *types.Error: + log.Errorf("failed to fetch message: %v", msg.Error) + app.PushError(args[0] + " error encountered: " + msg.Error.Error()) + done <- false + case *types.FullMessage: + mu.Lock() + err := mboxer.Write(file, msg.Content.Reader, "", t) + if err != nil { + log.Warnf("failed to write mbox: %v", err) + } + for i, uid := range uids { + if uid == msg.Content.Uid { + uids = append(uids[:i], uids[i+1:]...) + break + } + } + ctr++ + mu.Unlock() + } + }) + if ok := <-done; ok { + break + } + retries++ + } + statusInfo := fmt.Sprintf("Exported %d of %d messages to %s.", ctr, total, e.Filename) + app.PushStatus(statusInfo, 10*time.Second) + log.Debugf(statusInfo) + }() + + return nil +} + +func sortMarkedUids(marked []models.UID, store *lib.MessageStore) ([]models.UID, error) { + lookup := map[models.UID]bool{} + for _, uid := range marked { + lookup[uid] = true + } + uids := []models.UID{} + iter := store.UidsIterator() + for iter.Next() { + uid, ok := iter.Value().(models.UID) + if !ok { + return nil, errors.New("Invalid message UID value") + } + _, marked := lookup[uid] + if marked { + uids = append(uids, uid) + } + } + return uids, nil +} + +func sortAllUids(store *lib.MessageStore) ([]models.UID, error) { + uids := []models.UID{} + iter := store.UidsIterator() + for iter.Next() { + uid, ok := iter.Value().(models.UID) + if !ok { + return nil, errors.New("Invalid message UID value") + } + uids = append(uids, uid) + } + return uids, nil +} diff --git a/commands/account/import-mbox.go b/commands/account/import-mbox.go new file mode 100644 index 0000000..f2d6c3c --- /dev/null +++ b/commands/account/import-mbox.go @@ -0,0 +1,189 @@ +package account + +import ( + "bytes" + "compress/gzip" + "errors" + "fmt" + "io" + "net/http" + "os" + "regexp" + "sync/atomic" + "time" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/xdg" + "git.sr.ht/~rjarry/aerc/models" + mboxer "git.sr.ht/~rjarry/aerc/worker/mbox" + "git.sr.ht/~rjarry/aerc/worker/types" +) + +type ImportMbox struct { + Path string `opt:"path" complete:"CompleteFilename" desc:"Input file path or URL."` +} + +func init() { + commands.Register(ImportMbox{}) +} + +func (ImportMbox) Description() string { + return "Import all messages from an (gzipped) mbox file to the current folder." +} + +func (ImportMbox) Context() commands.CommandContext { + return commands.MESSAGE_LIST +} + +func (ImportMbox) Aliases() []string { + return []string{"import-mbox"} +} + +func (*ImportMbox) CompleteFilename(arg string) []string { + return commands.CompletePath(arg, false) +} + +func (i ImportMbox) Execute(args []string) error { + acct := app.SelectedAccount() + if acct == nil { + return errors.New("No account selected") + } + store := acct.Store() + if store == nil { + return errors.New("No message store selected") + } + + folder := acct.SelectedDirectory() + if folder == "" { + return errors.New("No directory selected") + } + + importFolder := func(r io.ReadCloser) { + defer log.PanicHandler() + defer r.Close() + + messages, err := mboxer.Read(r) + if err != nil { + app.PushError(err.Error()) + return + } + + var appended uint32 + for i, m := range messages { + done := make(chan bool) + var retries int = 4 + for retries > 0 { + var buf bytes.Buffer + r, err := m.NewReader() + if err != nil { + log.Errorf("could not get reader for uid %d", m.UID()) + break + } + nbytes, _ := io.Copy(&buf, r) + store.Append( + folder, + models.SeenFlag, + time.Now(), + &buf, + int(nbytes), + func(msg types.WorkerMessage) { + switch msg := msg.(type) { + case *types.Unsupported: + errMsg := fmt.Sprintf("%s: AppendMessage is unsupported", args[0]) + log.Errorf(errMsg) + app.PushError(errMsg) + return + case *types.Error: + log.Errorf("AppendMessage failed: %v", msg.Error) + done <- false + case *types.Done: + atomic.AddUint32(&appended, 1) + done <- true + } + }, + ) + + select { + case ok := <-done: + if ok { + retries = 0 + } else { + // error encountered; try to append again after a quick nap + retries -= 1 + sleeping := time.Duration((5 - retries) * 1e9) + + log.Debugf("sleeping for %s before append message %d again", sleeping, i) + time.Sleep(sleeping) + } + case <-time.After(30 * time.Second): + log.Warnf("timed-out; appended %d of %d", appended, len(messages)) + return + } + } + } + infoStr := fmt.Sprintf("%s: imported %d of %d successfully.", args[0], appended, len(messages)) + log.Debugf(infoStr) + app.PushSuccess(infoStr) + } + + var buf []byte + + path := i.Path + if ok, err := regexp.MatchString("^(http[s]\\:|www\\.)", path); ok && err == nil { + resp, err := http.Get(path) + if err != nil { + return err + } + buf, err = io.ReadAll(resp.Body) + _ = resp.Body.Close() + if err != nil { + return err + } + } else { + path = xdg.ExpandHome(path) + buf, err = os.ReadFile(path) + if err != nil { + return err + } + } + + var r io.ReadCloser + + // detect gzip format compressed files as specified in RFC 1952 + if len(buf) >= 2 && buf[0] == 0x1f && buf[1] == 0x8b { + var err error + r, err = gzip.NewReader(bytes.NewReader(buf)) + if err != nil { + return err + } + } else { + r = io.NopCloser(bytes.NewReader(buf)) + } + + statusInfo := fmt.Sprintln("Importing", path, "to folder", folder) + app.PushStatus(statusInfo, 10*time.Second) + log.Debugf(statusInfo) + + if len(store.Uids()) > 0 { + confirm := app.NewSelectorDialog( + "Selected directory is not empty", + fmt.Sprintf("Import mbox file to %s anyways?", folder), + []string{"No", "Yes"}, 0, app.SelectedAccountUiConfig(), + func(option string, err error) { + app.CloseDialog() + if option == "Yes" { + go importFolder(r) + } else { + _ = r.Close() + } + }, + ) + app.AddDialog(confirm) + } else { + go importFolder(r) + } + + return nil +} diff --git a/commands/account/mkdir.go b/commands/account/mkdir.go new file mode 100644 index 0000000..76b8f2f --- /dev/null +++ b/commands/account/mkdir.go @@ -0,0 +1,64 @@ +package account + +import ( + "errors" + "time" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/worker/types" + "git.sr.ht/~rjarry/go-opt/v2" +) + +type MakeDir struct { + Folder string `opt:"folder" complete:"CompleteFolder" desc:"Folder name."` +} + +func init() { + commands.Register(MakeDir{}) +} + +func (MakeDir) Description() string { + return "Create and change to a new folder." +} + +func (MakeDir) Context() commands.CommandContext { + return commands.MESSAGE_LIST +} + +func (MakeDir) Aliases() []string { + return []string{"mkdir"} +} + +func (*MakeDir) CompleteFolder(arg string) []string { + acct := app.SelectedAccount() + if acct == nil { + return nil + } + sep := app.SelectedAccount().Worker().PathSeparator() + return commands.FilterList( + acct.Directories().List(), arg, + func(s string) string { + return opt.QuoteArg(s) + sep + }, + ) +} + +func (m MakeDir) Execute(args []string) error { + acct := app.SelectedAccount() + if acct == nil { + return errors.New("No account selected") + } + acct.Worker().PostAction(&types.CreateDirectory{ + Directory: m.Folder, + }, func(msg types.WorkerMessage) { + switch msg := msg.(type) { + case *types.Done: + app.PushStatus("Directory created.", 10*time.Second) + acct.Directories().Open(m.Folder, "", 0, nil, false) + case *types.Error: + app.PushError(msg.Error.Error()) + } + }) + return nil +} diff --git a/commands/account/next-folder.go b/commands/account/next-folder.go new file mode 100644 index 0000000..eb07531 --- /dev/null +++ b/commands/account/next-folder.go @@ -0,0 +1,41 @@ +package account + +import ( + "errors" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" +) + +type NextPrevFolder struct { + Offset int `opt:"n" default:"1"` +} + +func init() { + commands.Register(NextPrevFolder{}) +} + +func (NextPrevFolder) Description() string { + return "Cycle to the next or previous folder shown in the sidebar." +} + +func (NextPrevFolder) Context() commands.CommandContext { + return commands.MESSAGE_LIST +} + +func (NextPrevFolder) Aliases() []string { + return []string{"next-folder", "prev-folder"} +} + +func (np NextPrevFolder) Execute(args []string) error { + acct := app.SelectedAccount() + if acct == nil { + return errors.New("No account selected") + } + if args[0] == "prev-folder" { + acct.Directories().NextPrev(-np.Offset) + } else { + acct.Directories().NextPrev(np.Offset) + } + return nil +} diff --git a/commands/account/next-result.go b/commands/account/next-result.go new file mode 100644 index 0000000..0fb7d0a --- /dev/null +++ b/commands/account/next-result.go @@ -0,0 +1,48 @@ +package account + +import ( + "errors" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/lib/ui" +) + +type NextPrevResult struct{} + +func init() { + commands.Register(NextPrevResult{}) +} + +func (NextPrevResult) Description() string { + return "Select the next or previous search result." +} + +func (NextPrevResult) Context() commands.CommandContext { + return commands.MESSAGE_LIST +} + +func (NextPrevResult) Aliases() []string { + return []string{"next-result", "prev-result"} +} + +func (NextPrevResult) Execute(args []string) error { + acct := app.SelectedAccount() + if acct == nil { + return errors.New("No account selected") + } + if args[0] == "prev-result" { + store := acct.Store() + if store != nil { + store.PrevResult() + } + ui.Invalidate() + } else { + store := acct.Store() + if store != nil { + store.NextResult() + } + ui.Invalidate() + } + return nil +} diff --git a/commands/account/next.go b/commands/account/next.go new file mode 100644 index 0000000..5454f1e --- /dev/null +++ b/commands/account/next.go @@ -0,0 +1,108 @@ +package account + +import ( + "errors" + "fmt" + "strconv" + "strings" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/lib" + "git.sr.ht/~rjarry/aerc/lib/ui" + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/types" +) + +type NextPrevMsg struct { + Amount int `opt:"n" default:"1" metavar:"<n>[%]" action:"ParseAmount"` + Percent bool +} + +func init() { + commands.Register(NextPrevMsg{}) +} + +func (NextPrevMsg) Description() string { + return "Select the next or previous message in the message list." +} + +func (NextPrevMsg) Context() commands.CommandContext { + return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER +} + +func (np *NextPrevMsg) ParseAmount(arg string) error { + if strings.HasSuffix(arg, "%") { + np.Percent = true + arg = strings.TrimSuffix(arg, "%") + } + i, err := strconv.ParseInt(arg, 10, 64) + if err != nil { + return err + } + np.Amount = int(i) + return nil +} + +func (NextPrevMsg) Aliases() []string { + return []string{"next", "next-message", "prev", "prev-message"} +} + +func (np NextPrevMsg) Execute(args []string) error { + acct := app.SelectedAccount() + if acct == nil { + return errors.New("No account selected") + } + store := acct.Store() + if store == nil { + return fmt.Errorf("No message store set.") + } + + n := np.Amount + if np.Percent { + n = int(float64(acct.Messages().Height()) * (float64(n) / 100.0)) + } + if args[0] == "prev-message" || args[0] == "prev" { + store.NextPrev(-n) + } else { + store.NextPrev(n) + } + + if mv, ok := app.SelectedTabContent().(*app.MessageViewer); ok { + reloadViewer := func(nextMsg *models.MessageInfo) { + if nextMsg.Error != nil { + app.PushError(nextMsg.Error.Error()) + return + } + lib.NewMessageStoreView(nextMsg, mv.MessageView().SeenFlagSet(), + store, app.CryptoProvider(), app.DecryptKeys, + func(view lib.MessageView, err error) { + if err != nil { + app.PushError(err.Error()) + return + } + nextMv, err := app.NewMessageViewer(acct, view) + if err != nil { + app.PushError(err.Error()) + return + } + app.ReplaceTab(mv, nextMv, + nextMsg.Envelope.Subject, true) + }) + } + if nextMsg := store.Selected(); nextMsg != nil { + reloadViewer(nextMsg) + } else { + store.FetchHeaders([]models.UID{store.SelectedUid()}, + func(msg types.WorkerMessage) { + if m, ok := msg.(*types.MessageInfo); ok { + reloadViewer(m.Info) + } + }) + } + } + + ui.Invalidate() + + return nil +} diff --git a/commands/account/query.go b/commands/account/query.go new file mode 100644 index 0000000..c30b2c7 --- /dev/null +++ b/commands/account/query.go @@ -0,0 +1,127 @@ +package account + +import ( + "errors" + "strings" + "time" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/worker/types" +) + +type Query struct { + Account string `opt:"-a" complete:"CompleteAccount" desc:"Account name."` + Name string `opt:"-n" desc:"Force name of virtual folder."` + Force bool `opt:"-f" desc:"Replace existing query if any."` + Query string `opt:"..." complete:"CompleteNotmuch" desc:"Notmuch query."` +} + +func init() { + commands.Register(Query{}) +} + +func (Query) Description() string { + return "Create a virtual folder using the specified notmuch query." +} + +func (Query) Context() commands.CommandContext { + return commands.MESSAGE_LIST +} + +func (Query) Aliases() []string { + return []string{"query"} +} + +func (Query) CompleteAccount(arg string) []string { + return commands.FilterList(app.AccountNames(), arg, commands.QuoteSpace) +} + +func (q Query) Execute([]string) error { + var acct *app.AccountView + + if q.Account == "" { + acct = app.SelectedAccount() + if acct == nil { + return errors.New("No account selected") + } + } else { + var err error + acct, err = app.Account(q.Account) + if err != nil { + return err + } + } + + if acct.AccountConfig().Backend != "notmuch" { + return errors.New(":query is only available for notmuch accounts") + } + + finalize := func(msg types.WorkerMessage) { + handleDirOpenResponse(acct, msg) + } + + name := q.Name + if name == "" { + name = q.Query + } + acct.Directories().Open(name, q.Query, 0*time.Second, finalize, q.Force) + return nil +} + +func (*Query) CompleteNotmuch(arg string) []string { + return handleNotmuchComplete(arg) +} + +var notmuch_search_terms = []string{ + "from:", + "to:", + "tag:", + "date:", + "attachment:", + "mimetype:", + "subject:", + "body:", + "id:", + "thread:", + "folder:", + "path:", +} + +func handleNotmuchComplete(arg string) []string { + prefixes := []string{"from:", "to:"} + for _, prefix := range prefixes { + if strings.HasPrefix(arg, prefix) { + arg = strings.TrimPrefix(arg, prefix) + return commands.FilterList( + commands.GetAddress(arg), arg, + func(v string) string { return prefix + v }, + ) + } + } + + prefixes = []string{"tag:"} + for _, prefix := range prefixes { + if strings.HasPrefix(arg, prefix) { + arg = strings.TrimPrefix(arg, prefix) + return commands.FilterList( + commands.GetLabels(arg), arg, + func(v string) string { return prefix + v }, + ) + } + } + + prefixes = []string{"path:", "folder:"} + dbPath := strings.TrimPrefix(app.SelectedAccount().AccountConfig().Source, "notmuch://") + for _, prefix := range prefixes { + if strings.HasPrefix(arg, prefix) { + arg = strings.TrimPrefix(arg, prefix) + return commands.FilterList( + commands.CompletePath(dbPath+arg, true), arg, + func(v string) string { return prefix + strings.TrimPrefix(v, dbPath) }, + ) + } + } + + return commands.FilterList(notmuch_search_terms, arg, nil) +} diff --git a/commands/account/recover.go b/commands/account/recover.go new file mode 100644 index 0000000..7f5b27f --- /dev/null +++ b/commands/account/recover.go @@ -0,0 +1,88 @@ +package account + +import ( + "bytes" + "errors" + "io" + "os" + "path/filepath" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/config" +) + +type Recover struct { + Force bool `opt:"-f" desc:"Delete recovered file after opening the composer."` + Edit bool `opt:"-e" desc:"Force [compose].edit-headers = true."` + NoEdit bool `opt:"-E" desc:"Force [compose].edit-headers = false."` + File string `opt:"file" complete:"CompleteFile" desc:"Recover file path."` +} + +func init() { + commands.Register(Recover{}) +} + +func (Recover) Description() string { + return "Resume composing a message that was not sent nor postponed." +} + +func (Recover) Context() commands.CommandContext { + return commands.MESSAGE_LIST +} + +func (Recover) Aliases() []string { + return []string{"recover"} +} + +func (Recover) Options() string { + return "feE" +} + +func (*Recover) CompleteFile(arg string) []string { + // file name of temp file is hard-coded in the NewComposer() function + files, err := filepath.Glob( + filepath.Join(os.TempDir(), "aerc-compose-*.eml"), + ) + if err != nil { + return nil + } + return commands.FilterList(files, arg, nil) +} + +func (r Recover) Execute(args []string) error { + file, err := os.Open(r.File) + if err != nil { + return err + } + defer file.Close() + data, err := io.ReadAll(file) + if err != nil { + return err + } + + acct := app.SelectedAccount() + if acct == nil { + return errors.New("No account selected") + } + + editHeaders := (config.Compose.EditHeaders || r.Edit) && !r.NoEdit + + composer, err := app.NewComposer(acct, + acct.AccountConfig(), acct.Worker(), editHeaders, + "", nil, nil, bytes.NewReader(data)) + if err != nil { + return err + } + composer.Tab = app.NewTab(composer, "Recovered") + + // remove file if force flag is set + if r.Force { + err = os.Remove(r.File) + if err != nil { + return err + } + } + + return nil +} diff --git a/commands/account/rmdir.go b/commands/account/rmdir.go new file mode 100644 index 0000000..b7a99c1 --- /dev/null +++ b/commands/account/rmdir.go @@ -0,0 +1,154 @@ +package account + +import ( + "errors" + "fmt" + "time" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/types" + "git.sr.ht/~rjarry/go-opt/v2" +) + +type RemoveDir struct { + Force bool `opt:"-f" desc:"Remove the directory even if it contains messages."` + Folder string `opt:"folder" complete:"CompleteFolder" required:"false" desc:"Folder name."` +} + +func init() { + commands.Register(RemoveDir{}) +} + +func (RemoveDir) Description() string { + return "Remove folder." +} + +func (RemoveDir) Context() commands.CommandContext { + return commands.MESSAGE_LIST +} + +func (RemoveDir) Aliases() []string { + return []string{"rmdir"} +} + +func (RemoveDir) CompleteFolder(arg string) []string { + acct := app.SelectedAccount() + if acct == nil { + return nil + } + return commands.FilterList(acct.Directories().List(), arg, opt.QuoteArg) +} + +func (r RemoveDir) Execute(args []string) error { + acct := app.SelectedAccount() + if acct == nil { + return errors.New("No account selected") + } + + current := acct.Directories().SelectedDirectory() + toRemove := current + if r.Folder != "" { + toRemove = acct.Directories().Directory(r.Folder) + if toRemove == nil { + return fmt.Errorf("No such directory: %s", r.Folder) + } + } + + role := toRemove.Role + + // Check for any messages in the directory. + if role != models.QueryRole && toRemove.Exists > 0 && !r.Force { + return errors.New("Refusing to remove non-empty directory; use -f") + } + + if role == models.VirtualRole { + return errors.New("Cannot remove a virtual node") + } + + if toRemove != current { + r.remove(acct, toRemove, func() {}) + return nil + } + + curDir := current.Name + var newDir string + dirFound := false + + oldDir := acct.Directories().Previous() + if oldDir != "" { + present := false + for _, dir := range acct.Directories().List() { + if dir == oldDir { + present = true + break + } + } + if oldDir != curDir && present { + newDir = oldDir + dirFound = true + } + } + + defaultDir := acct.AccountConfig().Default + if !dirFound && defaultDir != curDir { + for _, dir := range acct.Directories().List() { + if defaultDir == dir { + newDir = dir + dirFound = true + break + } + } + } + + if !dirFound { + for _, dir := range acct.Directories().List() { + if dir != curDir { + newDir = dir + dirFound = true + break + } + } + } + + if !dirFound { + return errors.New("No directory to move to afterwards!") + } + + reopenCurrentDir := func() { acct.Directories().Open(curDir, "", 0, nil, false) } + + acct.Directories().Open(newDir, "", 0, func(msg types.WorkerMessage) { + switch msg.(type) { + case *types.Done: + break + case *types.Error: + app.PushError("Could not change directory") + reopenCurrentDir() + return + default: + return + } + r.remove(acct, toRemove, reopenCurrentDir) + }, false) + + return nil +} + +func (r RemoveDir) remove(acct *app.AccountView, dir *models.Directory, onErr func()) { + acct.Worker().PostAction(&types.RemoveDirectory{ + Directory: dir.Name, + Quiet: r.Force, + }, func(msg types.WorkerMessage) { + switch msg := msg.(type) { + case *types.Done: + app.PushStatus("Directory removed.", 10*time.Second) + case *types.Error: + app.PushError(msg.Error.Error()) + onErr() + case *types.Unsupported: + app.PushError(":rmdir is not supported by the backend.") + onErr() + } + }) +} diff --git a/commands/account/search.go b/commands/account/search.go new file mode 100644 index 0000000..f64bcf9 --- /dev/null +++ b/commands/account/search.go @@ -0,0 +1,223 @@ +package account + +import ( + "errors" + "fmt" + "net/textproto" + "strings" + "time" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/parse" + "git.sr.ht/~rjarry/aerc/lib/state" + "git.sr.ht/~rjarry/aerc/lib/ui" + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/imap/extensions/xgmext" + "git.sr.ht/~rjarry/aerc/worker/types" +) + +type SearchFilter struct { + Read bool `opt:"-r" action:"ParseRead" desc:"Search for read messages."` + Unread bool `opt:"-u" action:"ParseUnread" desc:"Search for unread messages."` + Body bool `opt:"-b" desc:"Search in the body of the messages."` + All bool `opt:"-a" desc:"Search in the entire text of the messages."` + UseExtension bool `opt:"-e" desc:"Use custom search backend extension."` + Headers textproto.MIMEHeader `opt:"-H" action:"ParseHeader" metavar:"<header>:<value>" desc:"Search for messages with the specified header."` + WithFlags models.Flags `opt:"-x" action:"ParseFlag" complete:"CompleteFlag" desc:"Search messages with specified flag."` + WithoutFlags models.Flags `opt:"-X" action:"ParseNotFlag" complete:"CompleteFlag" desc:"Search messages without specified flag."` + To []string `opt:"-t" action:"ParseTo" complete:"CompleteAddress" desc:"Search for messages To:<address>."` + From []string `opt:"-f" action:"ParseFrom" complete:"CompleteAddress" desc:"Search for messages From:<address>."` + Cc []string `opt:"-c" action:"ParseCc" complete:"CompleteAddress" desc:"Search for messages Cc:<address>."` + StartDate time.Time `opt:"-d" action:"ParseDate" complete:"CompleteDate" desc:"Search for messages within a particular date range."` + EndDate time.Time + Terms string `opt:"..." required:"false" complete:"CompleteTerms" desc:"Search term."` +} + +func init() { + commands.Register(SearchFilter{}) +} + +func (SearchFilter) Description() string { + return "Search or filter the current folder." +} + +func (SearchFilter) Context() commands.CommandContext { + return commands.MESSAGE_LIST +} + +func (SearchFilter) Aliases() []string { + return []string{"search", "filter"} +} + +func (*SearchFilter) CompleteFlag(arg string) []string { + return commands.FilterList(commands.GetFlagList(), arg, commands.QuoteSpace) +} + +func (*SearchFilter) CompleteAddress(arg string) []string { + return commands.FilterList(commands.GetAddress(arg), arg, commands.QuoteSpace) +} + +func (*SearchFilter) CompleteDate(arg string) []string { + return commands.FilterList(commands.GetDateList(), arg, commands.QuoteSpace) +} + +func (s *SearchFilter) CompleteTerms(arg string) []string { + acct := app.SelectedAccount() + if acct == nil { + return nil + } + if acct.AccountConfig().Backend == "notmuch" { + return handleNotmuchComplete(arg) + } + caps := acct.Worker().Backend.Capabilities() + if caps != nil && caps.Has("X-GM-EXT-1") && s.UseExtension { + return handleXGMEXTComplete(arg) + } + return nil +} + +func (s *SearchFilter) ParseRead(arg string) error { + s.WithFlags |= models.SeenFlag + s.WithoutFlags &^= models.SeenFlag + return nil +} + +func (s *SearchFilter) ParseUnread(arg string) error { + s.WithFlags &^= models.SeenFlag + s.WithoutFlags |= models.SeenFlag + return nil +} + +var flagValues = map[string]models.Flags{ + "seen": models.SeenFlag, + "answered": models.AnsweredFlag, + "forwarded": models.ForwardedFlag, + "flagged": models.FlaggedFlag, + "draft": models.DraftFlag, +} + +func (s *SearchFilter) ParseFlag(arg string) error { + f, ok := flagValues[strings.ToLower(arg)] + if !ok { + return fmt.Errorf("%q unknown flag", arg) + } + s.WithFlags |= f + s.WithoutFlags &^= f + return nil +} + +func (s *SearchFilter) ParseNotFlag(arg string) error { + f, ok := flagValues[strings.ToLower(arg)] + if !ok { + return fmt.Errorf("%q unknown flag", arg) + } + s.WithFlags &^= f + s.WithoutFlags |= f + return nil +} + +func (s *SearchFilter) ParseHeader(arg string) error { + name, value, hasColon := strings.Cut(arg, ":") + if !hasColon { + return fmt.Errorf("%q invalid syntax", arg) + } + if s.Headers == nil { + s.Headers = make(textproto.MIMEHeader) + } + s.Headers.Add(name, strings.TrimSpace(value)) + return nil +} + +func (s *SearchFilter) ParseTo(arg string) error { + s.To = append(s.To, arg) + return nil +} + +func (s *SearchFilter) ParseFrom(arg string) error { + s.From = append(s.From, arg) + return nil +} + +func (s *SearchFilter) ParseCc(arg string) error { + s.Cc = append(s.Cc, arg) + return nil +} + +func (s *SearchFilter) ParseDate(arg string) error { + start, end, err := parse.DateRange(arg) + if err != nil { + return err + } + s.StartDate = start + s.EndDate = end + return nil +} + +func (s SearchFilter) Execute(args []string) error { + acct := app.SelectedAccount() + if acct == nil { + return errors.New("No account selected") + } + store := acct.Store() + if store == nil { + return errors.New("Cannot perform action. Messages still loading") + } + + criteria := types.SearchCriteria{ + WithFlags: s.WithFlags, + WithoutFlags: s.WithoutFlags, + From: s.From, + To: s.To, + Cc: s.Cc, + Headers: s.Headers, + StartDate: s.StartDate, + EndDate: s.EndDate, + SearchBody: s.Body, + SearchAll: s.All, + Terms: []string{s.Terms}, + UseExtension: s.UseExtension, + } + + if args[0] == "filter" { + if len(args[1:]) == 0 { + return Clear{}.Execute([]string{"clear"}) + } + acct.SetStatus(state.FilterActivity("Filtering..."), state.Search("")) + store.SetFilter(&criteria) + cb := func(msg types.WorkerMessage) { + if _, ok := msg.(*types.Done); ok { + acct.SetStatus(state.FilterResult(strings.Join(args, " "))) + log.Tracef("Filter results: %v", store.Uids()) + } + } + store.Sort(store.GetCurrentSortCriteria(), cb) + } else { + acct.SetStatus(state.Search("Searching...")) + cb := func(uids []models.UID) { + acct.SetStatus(state.Search(strings.Join(args, " "))) + log.Tracef("Search results: %v", uids) + store.ApplySearch(uids) + // TODO: Remove when stores have multiple OnUpdate handlers + ui.Invalidate() + } + store.Search(&criteria, cb) + } + return nil +} + +func handleXGMEXTComplete(arg string) []string { + prefixes := []string{"from:", "to:", "deliveredto:", "cc:", "bcc:"} + for _, prefix := range prefixes { + if strings.HasPrefix(arg, prefix) { + arg = strings.TrimPrefix(arg, prefix) + return commands.FilterList( + commands.GetAddress(arg), arg, + func(v string) string { return prefix + v }, + ) + } + } + + return commands.FilterList(xgmext.Terms, arg, nil) +} diff --git a/commands/account/select.go b/commands/account/select.go new file mode 100644 index 0000000..dda76af --- /dev/null +++ b/commands/account/select.go @@ -0,0 +1,40 @@ +package account + +import ( + "errors" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" +) + +type SelectMessage struct { + Index int `opt:"n"` +} + +func init() { + commands.Register(SelectMessage{}) +} + +func (SelectMessage) Description() string { + return "Select the <N>th message in the message list." +} + +func (SelectMessage) Context() commands.CommandContext { + return commands.MESSAGE_LIST +} + +func (SelectMessage) Aliases() []string { + return []string{"select", "select-message"} +} + +func (s SelectMessage) Execute(args []string) error { + acct := app.SelectedAccount() + if acct == nil { + return errors.New("No account selected") + } + if acct.Messages().Empty() { + return nil + } + acct.Messages().Select(s.Index) + return nil +} diff --git a/commands/account/sort.go b/commands/account/sort.go new file mode 100644 index 0000000..b381548 --- /dev/null +++ b/commands/account/sort.go @@ -0,0 +1,86 @@ +package account + +import ( + "errors" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/lib/sort" + "git.sr.ht/~rjarry/aerc/lib/state" + "git.sr.ht/~rjarry/aerc/worker/types" +) + +type Sort struct { + Unused struct{} `opt:"-"` + // these fields are only used for completion + Reverse bool `opt:"-r" desc:"Sort in the reverse order."` + Criteria []string `opt:"criteria" complete:"CompleteCriteria" desc:"Sort criterion."` +} + +func init() { + commands.Register(Sort{}) +} + +func (Sort) Description() string { + return "Sort the message list by the given criteria." +} + +func (Sort) Context() commands.CommandContext { + return commands.MESSAGE_LIST +} + +func (Sort) Aliases() []string { + return []string{"sort"} +} + +var supportedCriteria = []string{ + "arrival", + "cc", + "date", + "from", + "read", + "size", + "subject", + "to", + "flagged", +} + +func (*Sort) CompleteCriteria(arg string) []string { + return commands.FilterList(supportedCriteria, arg, commands.QuoteSpace) +} + +func (Sort) Execute(args []string) error { + acct := app.SelectedAccount() + if acct == nil { + return errors.New("No account selected.") + } + store := acct.Store() + if store == nil { + return errors.New("Messages still loading.") + } + + if c := store.Capabilities(); c != nil { + if !c.Sort { + return errors.New("Sorting is not available for this backend.") + } + } + + var err error + var sortCriteria []*types.SortCriterion + if len(args[1:]) == 0 { + sortCriteria = acct.GetSortCriteria() + } else { + sortCriteria, err = sort.GetSortCriteria(args[1:]) + if err != nil { + return err + } + } + + acct.SetStatus(state.Sorting(true)) + store.Sort(sortCriteria, func(msg types.WorkerMessage) { + if _, ok := msg.(*types.Done); ok { + acct.SetStatus(state.Sorting(false)) + } + }) + return nil +} diff --git a/commands/account/split.go b/commands/account/split.go new file mode 100644 index 0000000..efa89fc --- /dev/null +++ b/commands/account/split.go @@ -0,0 +1,82 @@ +package account + +import ( + "errors" + "strconv" + "strings" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" +) + +type Split struct { + Size int `opt:"n" required:"false" action:"ParseSize"` + Delta bool +} + +func init() { + commands.Register(Split{}) +} + +func (Split) Description() string { + return "Split the message list with a preview pane." +} + +func (Split) Context() commands.CommandContext { + return commands.MESSAGE_LIST +} + +func (s *Split) ParseSize(arg string) error { + i, err := strconv.ParseInt(arg, 10, 64) + if err != nil { + return err + } + s.Size = int(i) + if strings.HasPrefix(arg, "+") || strings.HasPrefix(arg, "-") { + s.Delta = true + } + return nil +} + +func (Split) Aliases() []string { + return []string{"split", "vsplit", "hsplit"} +} + +func (s Split) Execute(args []string) error { + acct := app.SelectedAccount() + if acct == nil { + return errors.New("No account selected") + } + store := app.SelectedAccount().Store() + if store == nil { + return errors.New("Cannot perform action. Messages still loading") + } + + if s.Size == 0 && acct.SplitSize() == 0 { + if args[0] == "split" || args[0] == "hsplit" { + s.Size = app.SelectedAccount().Messages().Height() / 4 + } else { + s.Size = app.SelectedAccount().Messages().Width() / 2 + } + } + if s.Delta { + acct.SetSplitSize(acct.SplitSize() + s.Size) + return nil + } + if s.Size == acct.SplitSize() { + // Repeated commands of the same size have the effect of + // toggling the split + s.Size = 0 + } + if s.Size < 0 { + // Don't allow split to go negative + s.Size = 1 + } + switch args[0] { + case "split", "hsplit": + acct.Split(s.Size) + case "vsplit": + acct.Vsplit(s.Size) + } + return nil +} diff --git a/commands/account/view.go b/commands/account/view.go new file mode 100644 index 0000000..72f62b5 --- /dev/null +++ b/commands/account/view.go @@ -0,0 +1,91 @@ +package account + +import ( + "bytes" + "errors" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/lib" + "git.sr.ht/~rjarry/aerc/lib/state" + "git.sr.ht/~rjarry/aerc/lib/templates" + "git.sr.ht/~rjarry/aerc/models" +) + +type ViewMessage struct { + Peek bool `opt:"-p" desc:"Peek message without marking it as read."` + Background bool `opt:"-b" desc:"Open message in a background tab."` +} + +func init() { + commands.Register(ViewMessage{}) +} + +func (ViewMessage) Description() string { + return "View the selected message in a new tab." +} + +func (ViewMessage) Context() commands.CommandContext { + return commands.MESSAGE_LIST +} + +func (ViewMessage) Aliases() []string { + return []string{"view-message", "view"} +} + +func (v ViewMessage) Execute(args []string) error { + acct := app.SelectedAccount() + if acct == nil { + return errors.New("No account selected") + } + if acct.Messages().Empty() { + return nil + } + store := acct.Messages().Store() + msg := acct.Messages().Selected() + if msg == nil { + return nil + } + _, deleted := store.Deleted[msg.Uid] + if deleted { + return nil + } + if msg.Error != nil { + app.PushError(msg.Error.Error()) + return nil + } + lib.NewMessageStoreView( + msg, + !v.Peek && acct.UiConfig().AutoMarkRead, + store, + app.CryptoProvider(), + app.DecryptKeys, + func(view lib.MessageView, err error) { + if err != nil { + app.PushError(err.Error()) + return + } + viewer, err := app.NewMessageViewer(acct, view) + if err != nil { + app.PushError(err.Error()) + return + } + data := state.NewDataSetter() + data.SetAccount(acct.AccountConfig()) + data.SetFolder(acct.Directories().SelectedDirectory()) + data.SetHeaders(msg.RFC822Headers, &models.OriginalMail{}) + var buf bytes.Buffer + err = templates.Render(acct.UiConfig().TabTitleViewer, &buf, + data.Data()) + if err != nil { + acct.PushError(err) + return + } + if v.Background { + app.NewBackgroundTab(viewer, buf.String()) + } else { + app.NewTab(viewer, buf.String()) + } + }) + return nil +} diff --git a/commands/cd.go b/commands/cd.go new file mode 100644 index 0000000..4a2fea0 --- /dev/null +++ b/commands/cd.go @@ -0,0 +1,55 @@ +package commands + +import ( + "errors" + "os" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/lib/xdg" +) + +var previousDir string + +type ChangeDirectory struct { + Target string `opt:"directory" default:"~" complete:"CompleteTarget" desc:"Target directory."` +} + +func init() { + Register(ChangeDirectory{}) +} + +func (ChangeDirectory) Description() string { + return "Change aerc's current working directory." +} + +func (ChangeDirectory) Context() CommandContext { + return GLOBAL +} + +func (ChangeDirectory) Aliases() []string { + return []string{"cd"} +} + +func (*ChangeDirectory) CompleteTarget(arg string) []string { + return CompletePath(arg, true) +} + +func (cd ChangeDirectory) Execute(args []string) error { + cwd, err := os.Getwd() + if err != nil { + return err + } + if cd.Target == "-" { + if previousDir == "" { + return errors.New("No previous folder to return to") + } else { + cd.Target = previousDir + } + } + target := xdg.ExpandHome(cd.Target) + if err := os.Chdir(target); err == nil { + previousDir = cwd + app.UpdateStatus() + } + return err +} diff --git a/commands/choose.go b/commands/choose.go new file mode 100644 index 0000000..6c3906c --- /dev/null +++ b/commands/choose.go @@ -0,0 +1,53 @@ +package commands + +import ( + "fmt" + + "git.sr.ht/~rjarry/aerc/app" +) + +type Choose struct { + Unused struct{} `opt:"-"` +} + +func init() { + Register(Choose{}) +} + +func (Choose) Description() string { + return "Prompt to choose from various options." +} + +func (Choose) Context() CommandContext { + return GLOBAL +} + +func (Choose) Aliases() []string { + return []string{"choose"} +} + +func (Choose) Execute(args []string) error { + if len(args) < 5 || len(args)%4 != 1 { + return chooseUsage(args[0]) + } + + choices := []app.Choice{} + for i := 0; i+4 < len(args); i += 4 { + if args[i+1] != "-o" { + return chooseUsage(args[0]) + } + choices = append(choices, app.Choice{ + Key: args[i+2], + Text: args[i+3], + Command: args[i+4], + }) + } + + app.RegisterChoices(choices) + + return nil +} + +func chooseUsage(cmd string) error { + return fmt.Errorf("Usage: %s -o <key> <text> <command> [-o <key> <text> <command>]...", cmd) +} diff --git a/commands/close.go b/commands/close.go new file mode 100644 index 0000000..177c54a --- /dev/null +++ b/commands/close.go @@ -0,0 +1,28 @@ +package commands + +import ( + "git.sr.ht/~rjarry/aerc/app" +) + +type Close struct{} + +func init() { + Register(Close{}) +} + +func (Close) Description() string { + return "Close the focused tab." +} + +func (Close) Context() CommandContext { + return MESSAGE_VIEWER | TERMINAL +} + +func (Close) Aliases() []string { + return []string{"close"} +} + +func (Close) Execute([]string) error { + app.RemoveTab(app.SelectedTabContent(), true) + return nil +} diff --git a/commands/commands.go b/commands/commands.go new file mode 100644 index 0000000..a4378ef --- /dev/null +++ b/commands/commands.go @@ -0,0 +1,364 @@ +package commands + +import ( + "bytes" + "errors" + "path" + "reflect" + "sort" + "strings" + "unicode" + + "git.sr.ht/~rjarry/go-opt/v2" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/state" + "git.sr.ht/~rjarry/aerc/lib/templates" + "git.sr.ht/~rjarry/aerc/models" +) + +type CommandContext uint32 + +const ( + NONE = 1 << iota + // available everywhere + GLOBAL + // only when a message list is focused + MESSAGE_LIST + // only when a message viewer is focused + MESSAGE_VIEWER + // only when a message composer editor is focused + COMPOSE_EDIT + // only when a message composer review screen is focused + COMPOSE_REVIEW + // only when a terminal + TERMINAL +) + +func CurrentContext() CommandContext { + var context CommandContext = GLOBAL + + switch tab := app.SelectedTabContent().(type) { + case *app.AccountView: + context |= MESSAGE_LIST + case *app.Composer: + if tab.Bindings() == "compose::review" { + context |= COMPOSE_REVIEW + } else { + context |= COMPOSE_EDIT + } + case *app.MessageViewer: + context |= MESSAGE_VIEWER + case *app.Terminal: + context |= TERMINAL + } + + return context +} + +type Command interface { + Description() string + Context() CommandContext + Aliases() []string + Execute([]string) error +} + +var allCommands map[string]Command + +func Register(cmd Command) { + if allCommands == nil { + allCommands = make(map[string]Command) + } + for _, alias := range cmd.Aliases() { + if allCommands[alias] != nil { + panic("duplicate command alias: " + alias) + } + allCommands[alias] = cmd + } +} + +func ActiveCommands() []Command { + var cmds []Command + context := CurrentContext() + seen := make(map[reflect.Type]bool) + + for _, cmd := range allCommands { + t := reflect.TypeOf(cmd) + if seen[t] { + continue + } + seen[t] = true + if cmd.Context()&context != 0 { + cmds = append(cmds, cmd) + } + } + + return cmds +} + +func ActiveCommandNames() []string { + var names []string + context := CurrentContext() + + for alias, cmd := range allCommands { + if cmd.Context()&context != 0 { + names = append(names, alias) + } + } + + return names +} + +type NoSuchCommand string + +func (err NoSuchCommand) Error() string { + return "Unknown command " + string(err) +} + +// Expand non-ambiguous command abbreviations. +// +// q --> quit +// ar --> archive +// im --> import-mbox +func ExpandAbbreviations(name string) (string, Command, error) { + context := CurrentContext() + name = strings.TrimLeft(name, ": \t") + + cmd, found := allCommands[name] + if found && cmd.Context()&context != 0 { + return name, cmd, nil + } + + var candidate Command + var candidateName string + + for alias, cmd := range allCommands { + if cmd.Context()&context == 0 || !strings.HasPrefix(alias, name) { + continue + } + if candidate != nil { + // We have more than one command partially + // matching the input. + return name, nil, NoSuchCommand(name) + } + // We have a partial match. + candidate = cmd + candidateName = alias + } + + if candidate == nil { + return name, nil, NoSuchCommand(name) + } + + return candidateName, candidate, nil +} + +func ResolveCommand( + cmdline string, acct *config.AccountConfig, msg *models.MessageInfo, +) (string, Command, error) { + cmdline, err := ExpandTemplates(cmdline, acct, msg) + if err != nil { + return "", nil, err + } + name, rest, didCut := strings.Cut(cmdline, " ") + name, cmd, err := ExpandAbbreviations(name) + if err != nil { + return "", nil, err + } + cmdline = name + if didCut { + cmdline += " " + rest + } + return cmdline, cmd, nil +} + +func templateData( + cfg *config.AccountConfig, + msg *models.MessageInfo, +) models.TemplateData { + var folder *models.Directory + + acct := app.SelectedAccount() + if acct != nil { + folder = acct.Directories().SelectedDirectory() + } + if cfg == nil && acct != nil { + cfg = acct.AccountConfig() + } + if msg == nil && acct != nil { + msg, _ = acct.SelectedMessage() + } + + data := state.NewDataSetter() + data.SetAccount(cfg) + data.SetFolder(folder) + data.SetInfo(msg, 0, false) + if acct != nil { + acct.SetStatus(func(s *state.AccountState, _ string) { + data.SetState(s) + }) + } + + return data.Data() +} + +func ExecuteCommand(cmd Command, cmdline string) error { + args := opt.LexArgs(cmdline) + if args.Count() == 0 { + return errors.New("No arguments") + } + log.Tracef("executing command %s", args.String()) + // copy zeroed struct + tmp := reflect.New(reflect.TypeOf(cmd)).Interface().(Command) + if err := opt.ArgsToStruct(args.Clone(), tmp); err != nil { + return err + } + return tmp.Execute(args.Args()) +} + +// expand template expressions +func ExpandTemplates( + s string, cfg *config.AccountConfig, msg *models.MessageInfo, +) (string, error) { + if strings.Contains(s, "{{") && strings.Contains(s, "}}") { + t, err := templates.ParseTemplate("execute", s) + if err != nil { + return "", err + } + + data := templateData(cfg, msg) + + var buf bytes.Buffer + err = templates.Render(t, &buf, data) + if err != nil { + return "", err + } + + s = buf.String() + } + + return s, nil +} + +func GetTemplateCompletion( + cmd string, +) ([]string, string, bool) { + countLeft := strings.Count(cmd, "{{") + if countLeft == 0 { + return nil, "", false + } + countRight := strings.Count(cmd, "}}") + + switch { + case countLeft > countRight: + // complete template terms + var i int + for i = len(cmd) - 1; i >= 0; i-- { + if strings.ContainsRune("{()| ", rune(cmd[i])) { + break + } + } + search, prefix := cmd[i+1:], cmd[:i+1] + padding := strings.Repeat(" ", + len(search)-len(strings.TrimLeft(search, " "))) + options := FilterList( + templates.Terms(), + strings.TrimSpace(search), + nil, + ) + return options, prefix + padding, true + case countLeft == countRight: + // expand template + s, err := ExpandTemplates(cmd, nil, nil) + if err != nil { + log.Warnf("template rendering failed: %v", err) + return nil, "", false + } + return []string{s}, "", true + } + + return nil, "", false +} + +// GetCompletions returns the completion options and the command prefix +func GetCompletions( + cmd Command, args *opt.Args, +) (options []opt.Completion, prefix string) { + // copy zeroed struct + tmp := reflect.New(reflect.TypeOf(cmd)).Interface().(Command) + s, err := args.ArgSafe(0) + if err != nil { + log.Errorf("completions error: %v", err) + return options, prefix + } + spec := opt.NewCmdSpec(s, tmp) + return spec.GetCompletions(args) +} + +func GetFolders(arg string) []string { + acct := app.SelectedAccount() + if acct == nil { + return make([]string, 0) + } + return FilterList(acct.Directories().List(), arg, nil) +} + +func GetTemplates(arg string) []string { + templates := make(map[string]bool) + for _, dir := range config.Templates.TemplateDirs { + for _, f := range listDir(dir, false) { + if !isDir(path.Join(dir, f)) { + templates[f] = true + } + } + } + names := make([]string, 0, len(templates)) + for n := range templates { + names = append(names, n) + } + sort.Strings(names) + return FilterList(names, arg, nil) +} + +func GetLabels(arg string) []string { + acct := app.SelectedAccount() + if acct == nil { + return make([]string, 0) + } + var prefix string + if arg != "" { + // + and - are used to denote tag addition / removal and need to + // be striped only the last tag should be completed, so that + // multiple labels can be selected + switch arg[0] { + case '+': + prefix = "+" + case '-': + prefix = "-" + } + arg = strings.TrimLeft(arg, "+-") + } + return FilterList(acct.Labels(), arg, func(s string) string { + return opt.QuoteArg(prefix+s) + " " + }) +} + +// hasCaseSmartPrefix checks whether s starts with prefix, using a case +// sensitive match if and only if prefix contains upper case letters. +func hasCaseSmartPrefix(s, prefix string) bool { + if hasUpper(prefix) { + return strings.HasPrefix(s, prefix) + } + return strings.HasPrefix(strings.ToLower(s), strings.ToLower(prefix)) +} + +func hasUpper(s string) bool { + for _, r := range s { + if unicode.IsUpper(r) { + return true + } + } + return false +} diff --git a/commands/completion_helpers.go b/commands/completion_helpers.go new file mode 100644 index 0000000..6017035 --- /dev/null +++ b/commands/completion_helpers.go @@ -0,0 +1,79 @@ +package commands + +import ( + "context" + "fmt" + "net/mail" + "strings" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/completer" + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/lib/log" +) + +// GetAddress uses the address-book-cmd for address completion +func GetAddress(search string) []string { + var options []string + + cmd := app.SelectedAccount().AccountConfig().AddressBookCmd + if cmd == "" { + cmd = config.Compose.AddressBookCmd + if cmd == "" { + return nil + } + } + + cmpl := completer.New(cmd, func(err error) { + app.PushError( + fmt.Sprintf("could not complete header: %v", err)) + log.Warnf("could not complete header: %v", err) + }) + + if cmpl != nil { + addrList, _ := cmpl.ForHeader("to")(context.Background(), search) + for _, full := range addrList { + addr, err := mail.ParseAddress(full.Value) + if err != nil { + continue + } + options = append(options, addr.Address) + } + } + + return options +} + +// GetFlagList returns a list of available flags for completion +func GetFlagList() []string { + return []string{"Seen", "Answered", "Forwarded", "Flagged", "Draft"} +} + +// GetDateList returns a list of date terms for completion +func GetDateList() []string { + return []string{ + "today", "yesterday", "this_week", "this_month", + "this_year", "last_week", "last_month", "last_year", + "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", + "Saturday", "Sunday", + } +} + +// Operands returns a slice without any option flags or mandatory option +// arguments +func Operands(args []string, spec string) []string { + var result []string + for i := 0; i < len(args); i++ { + if s := args[i]; s == "--" { + return args[i+1:] + } else if strings.HasPrefix(s, "-") && len(spec) > 0 { + r := string(s[len(s)-1]) + ":" + if strings.Contains(spec, r) { + i++ + } + continue + } + result = append(result, args[i]) + } + return result +} diff --git a/commands/completion_helpers_test.go b/commands/completion_helpers_test.go new file mode 100644 index 0000000..876dc26 --- /dev/null +++ b/commands/completion_helpers_test.go @@ -0,0 +1,44 @@ +package commands_test + +import ( + "strings" + "testing" + + "git.sr.ht/~rjarry/aerc/commands" +) + +func TestCommands_Operand(t *testing.T) { + tests := []struct { + args []string + spec string + want string + }{ + { + args: []string{"cmd", "-a", "-b", "arg1", "-c", "bla"}, + spec: "ab:c", + want: "cmdbla", + }, + { + args: []string{"cmd", "-a", "-b", "arg1", "-c", "--", "bla"}, + spec: "ab:c", + want: "bla", + }, + { + args: []string{"cmd", "-a", "-b", "arg1", "-c", "bla"}, + spec: "ab:c:", + want: "cmd", + }, + { + args: nil, + spec: "ab:c:", + want: "", + }, + } + for i, test := range tests { + arg := strings.Join(commands.Operands(test.args, test.spec), "") + if arg != test.want { + t.Errorf("failed test %d: want '%s', got '%s'", i, + test.want, arg) + } + } +} diff --git a/commands/compose/abort.go b/commands/compose/abort.go new file mode 100644 index 0000000..5f91172 --- /dev/null +++ b/commands/compose/abort.go @@ -0,0 +1,30 @@ +package compose + +import ( + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" +) + +type Abort struct{} + +func init() { + commands.Register(Abort{}) +} + +func (Abort) Description() string { + return "Close the composer without sending." +} + +func (Abort) Context() commands.CommandContext { + return commands.COMPOSE_EDIT | commands.COMPOSE_REVIEW +} + +func (Abort) Aliases() []string { + return []string{"abort"} +} + +func (Abort) Execute(args []string) error { + composer, _ := app.SelectedTabContent().(*app.Composer) + app.RemoveTab(composer, true) + return nil +} diff --git a/commands/compose/attach-key.go b/commands/compose/attach-key.go new file mode 100644 index 0000000..2f5fc04 --- /dev/null +++ b/commands/compose/attach-key.go @@ -0,0 +1,29 @@ +package compose + +import ( + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" +) + +type AttachKey struct{} + +func init() { + commands.Register(AttachKey{}) +} + +func (AttachKey) Description() string { + return "Attach the public key of the current account." +} + +func (AttachKey) Context() commands.CommandContext { + return commands.COMPOSE_EDIT | commands.COMPOSE_REVIEW +} + +func (AttachKey) Aliases() []string { + return []string{"attach-key"} +} + +func (AttachKey) Execute(args []string) error { + composer, _ := app.SelectedTabContent().(*app.Composer) + return composer.SetAttachKey(!composer.AttachKey()) +} diff --git a/commands/compose/attach.go b/commands/compose/attach.go new file mode 100644 index 0000000..249447b --- /dev/null +++ b/commands/compose/attach.go @@ -0,0 +1,217 @@ +package compose + +import ( + "bufio" + "bytes" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/lib" + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/ui" + "git.sr.ht/~rjarry/aerc/lib/xdg" + "github.com/pkg/errors" +) + +type Attach struct { + Menu bool `opt:"-m" desc:"Select files from file-picker-cmd."` + Name string `opt:"-r" desc:"<name> <cmd...>: Generate attachment from command output."` + Path string `opt:"path" required:"false" complete:"CompletePath" desc:"Attachment file path."` + Args string `opt:"..." required:"false"` +} + +func init() { + commands.Register(Attach{}) +} + +func (Attach) Description() string { + return "Attach the file at the given path to the email." +} + +func (Attach) Context() commands.CommandContext { + return commands.COMPOSE_EDIT | commands.COMPOSE_REVIEW +} + +func (Attach) Aliases() []string { + return []string{"attach"} +} + +func (*Attach) CompletePath(arg string) []string { + return commands.CompletePath(arg, false) +} + +func (a Attach) Execute(args []string) error { + if a.Menu && a.Name != "" { + return errors.New("-m and -r are mutually exclusive") + } + switch { + case a.Menu: + return a.openMenu() + case a.Name != "": + if a.Path == "" { + return errors.New("command is required") + } + return a.readCommand() + default: + if a.Args != "" { + return errors.New("only a single path is supported") + } + return a.addPath(a.Path) + } +} + +func (a Attach) addPath(path string) error { + path = xdg.ExpandHome(path) + attachments, err := filepath.Glob(path) + if err != nil && errors.Is(err, filepath.ErrBadPattern) { + log.Warnf("failed to parse as globbing pattern: %v", err) + attachments = []string{path} + } + + if !strings.HasPrefix(path, ".") && !strings.Contains(path, "/.") { + log.Debugf("removing hidden files from glob results") + for i := len(attachments) - 1; i >= 0; i-- { + if strings.HasPrefix(filepath.Base(attachments[i]), ".") { + if i == len(attachments)-1 { + attachments = attachments[:i] + continue + } + attachments = append(attachments[:i], attachments[i+1:]...) + } + } + } + + composer, _ := app.SelectedTabContent().(*app.Composer) + for _, attach := range attachments { + log.Debugf("attaching '%s'", attach) + + pathinfo, err := os.Stat(attach) + if err != nil { + log.Errorf("failed to stat file: %v", err) + app.PushError(err.Error()) + return err + } else if pathinfo.IsDir() && len(attachments) == 1 { + app.PushError("Attachment must be a file, not a directory") + return nil + } + + composer.AddAttachment(attach) + } + + if len(attachments) == 1 { + app.PushSuccess(fmt.Sprintf("Attached %s", path)) + } else { + app.PushSuccess(fmt.Sprintf("Attached %d files", len(attachments))) + } + + return nil +} + +func (a Attach) openMenu() error { + filePickerCmd := config.Compose.FilePickerCmd + if filePickerCmd == "" { + return fmt.Errorf("no file-picker-cmd defined") + } + + if strings.Contains(filePickerCmd, "%s") { + filePickerCmd = strings.ReplaceAll(filePickerCmd, "%s", a.Path) + } + + picks, err := os.CreateTemp("", "aerc-filepicker-*") + if err != nil { + return err + } + + var filepicker *exec.Cmd + if strings.Contains(filePickerCmd, "%f") { + filePickerCmd = strings.ReplaceAll(filePickerCmd, "%f", picks.Name()) + filepicker = exec.Command("sh", "-c", filePickerCmd) + } else { + filepicker = exec.Command("sh", "-c", filePickerCmd+" >&3") + filepicker.ExtraFiles = append(filepicker.ExtraFiles, picks) + } + + t, err := app.NewTerminal(filepicker) + if err != nil { + return err + } + t.Focus(true) + t.OnClose = func(err error) { + defer func() { + if err := picks.Close(); err != nil { + log.Errorf("error closing file: %v", err) + } + if err := os.Remove(picks.Name()); err != nil { + log.Errorf("could not remove tmp file: %v", err) + } + }() + + app.CloseDialog() + + if err != nil { + log.Errorf("terminal closed with error: %v", err) + return + } + + _, err = picks.Seek(0, io.SeekStart) + if err != nil { + log.Errorf("seek failed: %v", err) + return + } + + scanner := bufio.NewScanner(picks) + for scanner.Scan() { + f := strings.TrimSpace(scanner.Text()) + if _, err := os.Stat(f); err != nil { + continue + } + log.Tracef("File picker attaches: %v", f) + err := a.addPath(f) + if err != nil { + log.Errorf("attach failed for file %s: %v", f, err) + } + + } + } + + app.AddDialog(app.DefaultDialog( + ui.NewBox(t, "File Picker", "", app.SelectedAccountUiConfig()), + )) + + return nil +} + +func (a Attach) readCommand() error { + cmd := exec.Command("sh", "-c", a.Path+" "+a.Args) + + data, err := cmd.Output() + if err != nil { + return errors.Wrap(err, "Output") + } + + reader := bufio.NewReader(bytes.NewReader(data)) + + mimeType, mimeParams, err := lib.FindMimeType(a.Name, reader) + if err != nil { + return errors.Wrap(err, "FindMimeType") + } + + mimeParams["name"] = a.Name + + composer, _ := app.SelectedTabContent().(*app.Composer) + err = composer.AddPartAttachment(a.Name, mimeType, mimeParams, reader) + if err != nil { + return errors.Wrap(err, "AddPartAttachment") + } + + app.PushSuccess(fmt.Sprintf("Attached %s", a.Name)) + + return nil +} diff --git a/commands/compose/cc-bcc.go b/commands/compose/cc-bcc.go new file mode 100644 index 0000000..b3460f7 --- /dev/null +++ b/commands/compose/cc-bcc.go @@ -0,0 +1,43 @@ +package compose + +import ( + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" +) + +type CC struct { + Recipients string `opt:"recipients" complete:"CompleteAddress" desc:"Recipient from address book."` +} + +func init() { + commands.Register(CC{}) +} + +func (CC) Description() string { + return "Add the given address(es) to the Cc or Bcc header." +} + +func (CC) Context() commands.CommandContext { + return commands.COMPOSE_EDIT | commands.COMPOSE_REVIEW +} + +func (CC) Aliases() []string { + return []string{"cc", "bcc"} +} + +func (*CC) CompleteAddress(arg string) []string { + return commands.GetAddress(arg) +} + +func (c CC) Execute(args []string) error { + composer, _ := app.SelectedTabContent().(*app.Composer) + + switch args[0] { + case "cc": + return composer.AddEditor("Cc", c.Recipients, true) + case "bcc": + return composer.AddEditor("Bcc", c.Recipients, true) + } + + return nil +} diff --git a/commands/compose/detach.go b/commands/compose/detach.go new file mode 100644 index 0000000..06e4b68 --- /dev/null +++ b/commands/compose/detach.go @@ -0,0 +1,93 @@ +package compose + +import ( + "fmt" + "path/filepath" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/lib/log" + "github.com/pkg/errors" +) + +type Detach struct { + Path string `opt:"path" required:"false" complete:"CompletePath" desc:"Attachment file path."` +} + +func init() { + commands.Register(Detach{}) +} + +func (Detach) Description() string { + return "Detach the file with the given path from the composed email." +} + +func (Detach) Context() commands.CommandContext { + return commands.COMPOSE_EDIT | commands.COMPOSE_REVIEW +} + +func (Detach) Aliases() []string { + return []string{"detach"} +} + +func (*Detach) CompletePath(arg string) []string { + composer, _ := app.SelectedTabContent().(*app.Composer) + return commands.FilterList(composer.GetAttachments(), arg, nil) +} + +func (d Detach) Execute(args []string) error { + composer, _ := app.SelectedTabContent().(*app.Composer) + + if d.Path == "" { + // if no attachment is specified, delete the first in the list + atts := composer.GetAttachments() + if len(atts) > 0 { + d.Path = atts[0] + } else { + return fmt.Errorf("No attachments to delete") + } + } + + return d.removePath(d.Path) +} + +func (d Detach) removePath(path string) error { + composer, _ := app.SelectedTabContent().(*app.Composer) + + // If we don't get an error here, the path was not a pattern. + if err := composer.DeleteAttachment(path); err == nil { + log.Debugf("detaching '%s'", path) + app.PushSuccess(fmt.Sprintf("Detached %s", path)) + + return nil + } + + currentAttachments := composer.GetAttachments() + detached := make([]string, 0, len(currentAttachments)) + for _, a := range currentAttachments { + // Don't use filepath.Glob like :attach does. Not all files + // that match the glob are already attached to the message. + matches, err := filepath.Match(path, a) + if err != nil && errors.Is(err, filepath.ErrBadPattern) { + log.Warnf("failed to parse as globbing pattern: %v", err) + return err + } + + if matches { + log.Debugf("detaching '%s'", a) + if err := composer.DeleteAttachment(a); err != nil { + return err + } + + detached = append(detached, a) + } + } + + if len(detached) == 1 { + app.PushSuccess(fmt.Sprintf("Detached %s", detached[0])) + } else { + app.PushSuccess(fmt.Sprintf("Detached %d files", len(detached))) + } + + return nil +} diff --git a/commands/compose/edit.go b/commands/compose/edit.go new file mode 100644 index 0000000..81cccff --- /dev/null +++ b/commands/compose/edit.go @@ -0,0 +1,46 @@ +package compose + +import ( + "errors" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/config" +) + +type Edit struct { + Edit bool `opt:"-e" desc:"Force [compose].edit-headers = true."` + NoEdit bool `opt:"-E" desc:"Force [compose].edit-headers = false."` +} + +func init() { + commands.Register(Edit{}) +} + +func (Edit) Description() string { + return "(Re-)open text editor to edit the message in progress." +} + +func (Edit) Context() commands.CommandContext { + return commands.COMPOSE_REVIEW +} + +func (Edit) Aliases() []string { + return []string{"edit"} +} + +func (e Edit) Execute(args []string) error { + composer, ok := app.SelectedTabContent().(*app.Composer) + if !ok { + return errors.New("only valid while composing") + } + + editHeaders := (config.Compose.EditHeaders || e.Edit) && !e.NoEdit + + err := composer.ShowTerminal(editHeaders) + if err != nil { + return err + } + composer.FocusTerminal() + return nil +} diff --git a/commands/compose/encrypt.go b/commands/compose/encrypt.go new file mode 100644 index 0000000..cfab6f9 --- /dev/null +++ b/commands/compose/encrypt.go @@ -0,0 +1,30 @@ +package compose + +import ( + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" +) + +type Encrypt struct{} + +func init() { + commands.Register(Encrypt{}) +} + +func (Encrypt) Description() string { + return "Toggle encryption of the message to all recipients." +} + +func (Encrypt) Context() commands.CommandContext { + return commands.COMPOSE_EDIT | commands.COMPOSE_REVIEW +} + +func (Encrypt) Aliases() []string { + return []string{"encrypt"} +} + +func (Encrypt) Execute(args []string) error { + composer, _ := app.SelectedTabContent().(*app.Composer) + composer.SetEncrypt(!composer.Encrypt()) + return nil +} diff --git a/commands/compose/header.go b/commands/compose/header.go new file mode 100644 index 0000000..0fa1422 --- /dev/null +++ b/commands/compose/header.go @@ -0,0 +1,74 @@ +package compose + +import ( + "fmt" + "strings" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" +) + +type Header struct { + Force bool `opt:"-f" desc:"Overwrite any existing header."` + Remove bool `opt:"-d" desc:"Remove the header instead of adding it."` + Name string `opt:"name" complete:"CompleteHeaders" desc:"Header name."` + Value string `opt:"..." required:"false"` +} + +var headers = []string{ + "From", + "To", + "Cc", + "Bcc", + "Subject", + "Comments", + "Keywords", +} + +func init() { + commands.Register(Header{}) +} + +func (Header) Description() string { + return "Add or remove the specified email header." +} + +func (Header) Context() commands.CommandContext { + return commands.COMPOSE_EDIT | commands.COMPOSE_REVIEW +} + +func (Header) Aliases() []string { + return []string{"header"} +} + +func (Header) Options() string { + return "fd" +} + +func (*Header) CompleteHeaders(arg string) []string { + return commands.FilterList(headers, arg, commands.QuoteSpace) +} + +func (h Header) Execute(args []string) error { + composer, _ := app.SelectedTabContent().(*app.Composer) + + name := strings.TrimRight(h.Name, ":") + + if h.Remove { + return composer.DelEditor(name) + } + + if !h.Force { + headers, err := composer.PrepareHeader() + if err != nil { + return err + } + if headers.Get(name) != "" && h.Value != "" { + return fmt.Errorf( + "Header %s is already set to %q (use -f to overwrite)", + name, headers.Get(name)) + } + } + + return composer.AddEditor(name, h.Value, false) +} diff --git a/commands/compose/multipart.go b/commands/compose/multipart.go new file mode 100644 index 0000000..60041cb --- /dev/null +++ b/commands/compose/multipart.go @@ -0,0 +1,66 @@ +package compose + +import ( + "fmt" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/config" +) + +type Multipart struct { + Remove bool `opt:"-d" desc:"Remove the specified mime/type."` + Mime string `opt:"mime" metavar:"<mime/type>" complete:"CompleteMime" desc:"MIME/type name."` +} + +func init() { + commands.Register(Multipart{}) +} + +func (Multipart) Description() string { + return "Convert the message to multipart with the given mime/type part." +} + +func (Multipart) Context() commands.CommandContext { + return commands.COMPOSE_EDIT | commands.COMPOSE_REVIEW +} + +func (Multipart) Aliases() []string { + return []string{"multipart"} +} + +func (*Multipart) CompleteMime(arg string) []string { + var completions []string + for mime := range config.Converters { + completions = append(completions, mime) + } + return commands.FilterList(completions, arg, nil) +} + +func (m Multipart) Execute(args []string) error { + composer, ok := app.SelectedTabContent().(*app.Composer) + if !ok { + return fmt.Errorf(":multipart is only available on the compose::review screen") + } + + if m.Remove { + return composer.RemovePart(m.Mime) + } else { + _, found := config.Converters[m.Mime] + if !found { + return fmt.Errorf("no command defined for MIME type: %s", m.Mime) + } + err := composer.AppendPart( + m.Mime, + map[string]string{"Charset": "UTF-8"}, + // the actual content of the part will be rendered + // every time the body of the email is updated + nil, + ) + if err != nil { + return err + } + } + + return nil +} diff --git a/commands/compose/next-field.go b/commands/compose/next-field.go new file mode 100644 index 0000000..ea436f5 --- /dev/null +++ b/commands/compose/next-field.go @@ -0,0 +1,40 @@ +package compose + +import ( + "fmt" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" +) + +type NextPrevField struct{} + +func init() { + commands.Register(NextPrevField{}) +} + +func (NextPrevField) Description() string { + return "Cycle between header input fields." +} + +func (NextPrevField) Context() commands.CommandContext { + return commands.COMPOSE_EDIT +} + +func (NextPrevField) Aliases() []string { + return []string{"next-field", "prev-field"} +} + +func (NextPrevField) Execute(args []string) error { + composer, _ := app.SelectedTabContent().(*app.Composer) + var ok bool + if args[0] == "prev-field" { + ok = composer.PrevField() + } else { + ok = composer.NextField() + } + if !ok { + return fmt.Errorf("%s not available when edit-headers=true", args[0]) + } + return nil +} diff --git a/commands/compose/postpone.go b/commands/compose/postpone.go new file mode 100644 index 0000000..4f52ac5 --- /dev/null +++ b/commands/compose/postpone.go @@ -0,0 +1,149 @@ +package compose + +import ( + "bytes" + "time" + + "github.com/pkg/errors" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/types" +) + +type Postpone struct { + Folder string `opt:"-t" complete:"CompleteFolder" desc:"Override the target folder."` +} + +func init() { + commands.Register(Postpone{}) +} + +func (Postpone) Description() string { + return "Save the current state of the message to the postpone folder." +} + +func (Postpone) Context() commands.CommandContext { + return commands.COMPOSE_REVIEW +} + +func (Postpone) Aliases() []string { + return []string{"postpone"} +} + +func (*Postpone) CompleteFolder(arg string) []string { + return commands.GetFolders(arg) +} + +func (p Postpone) Execute(args []string) error { + acct := app.SelectedAccount() + if acct == nil { + return errors.New("No account selected") + } + store := acct.Store() + if store == nil { + return errors.New("No message store selected") + } + tab := app.SelectedTab() + if tab == nil { + return errors.New("No tab selected") + } + composer, _ := tab.Content.(*app.Composer) + config := composer.Config() + tabName := tab.Name + + targetFolder := config.Postpone + if composer.RecalledFrom() != "" { + targetFolder = composer.RecalledFrom() + } + if p.Folder != "" { + targetFolder = p.Folder + } + if targetFolder == "" { + return errors.New("No Postpone location configured") + } + + log.Tracef("Postponing mail") + + header, err := composer.PrepareHeader() + if err != nil { + return errors.Wrap(err, "PrepareHeader") + } + header.SetContentType("text/plain", map[string]string{"charset": "UTF-8"}) + header.Set("Content-Transfer-Encoding", "quoted-printable") + worker := composer.Worker() + dirs := acct.Directories().List() + alreadyCreated := false + for _, dir := range dirs { + if dir == targetFolder { + alreadyCreated = true + break + } + } + + errChan := make(chan string) + + // run this as a goroutine so we can make other progress. The message + // will be saved once the directory is created. + go func() { + defer log.PanicHandler() + + errStr := <-errChan + if errStr != "" { + app.PushError(errStr) + return + } + + handleErr := func(err error) { + app.PushError(err.Error()) + log.Errorf("Postponing failed: %v", err) + app.NewTab(composer, tabName) + } + + app.RemoveTab(composer, false) + buf := &bytes.Buffer{} + + err = composer.WriteMessage(header, buf) + if err != nil { + handleErr(errors.Wrap(err, "WriteMessage")) + return + } + store.Append( + targetFolder, + models.SeenFlag|models.DraftFlag, + time.Now(), + buf, + buf.Len(), + func(msg types.WorkerMessage) { + switch msg := msg.(type) { + case *types.Done: + app.PushStatus("Message postponed.", 10*time.Second) + composer.SetPostponed() + composer.Close() + case *types.Error: + handleErr(msg.Error) + } + }, + ) + }() + + if !alreadyCreated { + // to synchronise the creating of the directory + worker.PostAction(&types.CreateDirectory{ + Directory: targetFolder, + }, func(msg types.WorkerMessage) { + switch msg := msg.(type) { + case *types.Done: + errChan <- "" + case *types.Error: + errChan <- msg.Error.Error() + } + }) + } else { + errChan <- "" + } + + return nil +} diff --git a/commands/compose/send.go b/commands/compose/send.go new file mode 100644 index 0000000..aa05f72 --- /dev/null +++ b/commands/compose/send.go @@ -0,0 +1,328 @@ +package compose + +import ( + "bytes" + "context" + "fmt" + "io" + "net/url" + "strings" + "time" + + "github.com/pkg/errors" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/commands/mode" + "git.sr.ht/~rjarry/aerc/commands/msg" + "git.sr.ht/~rjarry/aerc/lib/hooks" + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/send" + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/types" + "git.sr.ht/~rjarry/go-opt/v2" + "github.com/emersion/go-message/mail" +) + +type Send struct { + Archive string `opt:"-a" action:"ParseArchive" metavar:"flat|year|month" complete:"CompleteArchive" desc:"Archive the message being replied to."` + CopyTo []string `opt:"-t" complete:"CompleteFolders" action:"ParseCopyTo" desc:"Override the Copy-To folders."` + + CopyToReplied bool `opt:"-r" desc:"Save sent message to current folder."` + NoCopyToReplied bool `opt:"-R" desc:"Do not save sent message to current folder."` +} + +func init() { + commands.Register(Send{}) +} + +func (Send) Description() string { + return "Send the message using the configured outgoing transport." +} + +func (Send) Context() commands.CommandContext { + return commands.COMPOSE_REVIEW +} + +func (Send) Aliases() []string { + return []string{"send"} +} + +func (*Send) CompleteArchive(arg string) []string { + return commands.FilterList(msg.ARCHIVE_TYPES, arg, nil) +} + +func (*Send) CompleteFolders(arg string) []string { + return commands.GetFolders(arg) +} + +func (s *Send) ParseArchive(arg string) error { + for _, a := range msg.ARCHIVE_TYPES { + if a == arg { + s.Archive = arg + return nil + } + } + return errors.New("unsupported archive type") +} + +func (o *Send) ParseCopyTo(arg string) error { + o.CopyTo = append(o.CopyTo, strings.Split(arg, ",")...) + return nil +} + +func (s Send) Execute(args []string) error { + tab := app.SelectedTab() + if tab == nil { + return errors.New("No selected tab") + } + composer, _ := tab.Content.(*app.Composer) + + err := composer.CheckForMultipartErrors() + if err != nil { + return err + } + + config := composer.Config() + + if len(s.CopyTo) == 0 { + s.CopyTo = config.CopyTo + } + copyToReplied := config.CopyToReplied || (s.CopyToReplied && !s.NoCopyToReplied) + + outgoing, err := config.Outgoing.ConnectionString() + if err != nil { + return errors.Wrap(err, "ReadCredentials(outgoing)") + } + if outgoing == "" { + return errors.New( + "No outgoing mail transport configured for this account") + } + + header, err := composer.PrepareHeader() + if err != nil { + return errors.Wrap(err, "PrepareHeader") + } + rcpts, err := listRecipients(header) + if err != nil { + return errors.Wrap(err, "listRecipients") + } + if len(rcpts) == 0 { + return errors.New("Cannot send message with no recipients") + } + + if config.StripBcc { + // Do NOT leak Bcc addresses to all recipients. + header.Del("Bcc") + } + + uri, err := url.Parse(outgoing) + if err != nil { + return errors.Wrap(err, "url.Parse(outgoing)") + } + + var domain string + if domain_, ok := config.Params["smtp-domain"]; ok { + domain = domain_ + } + from := config.From + if config.UseEnvelopeFrom { + if fl, _ := header.AddressList("from"); len(fl) != 0 { + from = fl[0] + } + } + + log.Debugf("send config uri: %s", uri.Redacted()) + log.Debugf("send config from: %s", from) + log.Debugf("send config rcpts: %s", rcpts) + log.Debugf("send config domain: %s", domain) + + warnSubject := composer.ShouldWarnSubject() + warnAttachment := composer.ShouldWarnAttachment() + if warnSubject || warnAttachment { + var msg string + switch { + case warnSubject && warnAttachment: + msg = "The subject is empty, and you may have forgotten an attachment." + case warnSubject: + msg = "The subject is empty." + default: + msg = "You may have forgotten an attachment." + } + + prompt := app.NewPrompt( + msg+" Abort send? [Y/n] ", + func(text string) { + if text == "n" || text == "N" { + sendHelper(composer, header, uri, domain, + from, rcpts, tab.Name, s.CopyTo, + s.Archive, copyToReplied) + } + }, func(ctx context.Context, cmd string) ([]opt.Completion, string) { + var comps []opt.Completion + if cmd == "" { + comps = append(comps, opt.Completion{Value: "y"}) + comps = append(comps, opt.Completion{Value: "n"}) + } + return comps, "" + }, + ) + + app.PushPrompt(prompt) + } else { + sendHelper(composer, header, uri, domain, from, rcpts, tab.Name, + s.CopyTo, s.Archive, copyToReplied) + } + + return nil +} + +func sendHelper(composer *app.Composer, header *mail.Header, uri *url.URL, domain string, + from *mail.Address, rcpts []*mail.Address, tabName string, copyTo []string, + archive string, copyToReplied bool, +) { + // we don't want to block the UI thread while we are sending + // so we do everything in a goroutine and hide the composer from the user + app.RemoveTab(composer, false) + app.PushStatus("Sending...", 10*time.Second) + + // enter no-quit mode + mode.NoQuit() + + var shouldCopy bool = (len(copyTo) > 0 || copyToReplied) && !strings.HasPrefix(uri.Scheme, "jmap") + var copyBuf bytes.Buffer + + failCh := make(chan error) + // writer + go func() { + defer log.PanicHandler() + + var folders []string + folders = append(folders, copyTo...) + if copyToReplied && composer.Parent() != nil { + folders = append(folders, composer.Parent().Folder) + } + sender, err := send.NewSender( + composer.Worker(), uri, domain, from, rcpts, folders) + if err != nil { + failCh <- errors.Wrap(err, "send:") + return + } + + var writer io.Writer = sender + + if shouldCopy { + writer = io.MultiWriter(writer, ©Buf) + } + + err = composer.WriteMessage(header, writer) + if err != nil { + failCh <- err + return + } + failCh <- sender.Close() + }() + + // cleanup + copy to sent + go func() { + defer log.PanicHandler() + + // leave no-quit mode + defer mode.NoQuitDone() + + err := <-failCh + if err != nil { + app.PushError(strings.ReplaceAll(err.Error(), "\n", " ")) + app.NewTab(composer, tabName) + return + } + if shouldCopy { + app.PushStatus("Copying to copy-to folders", 10*time.Second) + errch := copyToSent(copyTo, copyToReplied, copyBuf.Len(), + ©Buf, composer) + err = <-errch + if err != nil { + errmsg := fmt.Sprintf( + "message sent, but copying to %v failed: %v", + copyTo, err.Error()) + app.PushError(errmsg) + composer.SetSent(archive) + composer.Close() + return + } + } + app.PushStatus("Message sent.", 10*time.Second) + composer.SetSent(archive) + err = hooks.RunHook(&hooks.MailSent{ + Account: composer.Account().Name(), + Backend: composer.Account().AccountConfig().Backend, + Header: header, + }) + if err != nil { + log.Errorf("failed to trigger mail-sent hook: %v", err) + composer.Account().PushError(fmt.Errorf("[hook.mail-sent] failed: %w", err)) + } + composer.Close() + }() +} + +func listRecipients(h *mail.Header) ([]*mail.Address, error) { + var rcpts []*mail.Address + for _, key := range []string{"to", "cc", "bcc"} { + list, err := h.AddressList(key) + if err != nil { + return nil, err + } + rcpts = append(rcpts, list...) + } + return rcpts, nil +} + +func copyToSent(dests []string, copyToReplied bool, n int, msg *bytes.Buffer, composer *app.Composer) <-chan error { + errCh := make(chan error, 1) + acct := composer.Account() + if acct == nil { + errCh <- errors.New("No account selected") + return errCh + } + store := acct.Store() + if store == nil { + errCh <- errors.New("No message store selected") + return errCh + } + for _, dest := range dests { + store.Append( + dest, + models.SeenFlag, + time.Now(), + bytes.NewReader(msg.Bytes()), + n, + func(msg types.WorkerMessage) { + switch msg := msg.(type) { + case *types.Done: + errCh <- nil + case *types.Error: + errCh <- msg.Error + } + }, + ) + } + if copyToReplied && composer.Parent() != nil { + store.Append( + composer.Parent().Folder, + models.SeenFlag, + time.Now(), + bytes.NewReader(msg.Bytes()), + n, + func(msg types.WorkerMessage) { + switch msg := msg.(type) { + case *types.Done: + errCh <- nil + case *types.Error: + errCh <- msg.Error + } + }, + ) + } + return errCh +} diff --git a/commands/compose/sign.go b/commands/compose/sign.go new file mode 100644 index 0000000..d74eb00 --- /dev/null +++ b/commands/compose/sign.go @@ -0,0 +1,47 @@ +package compose + +import ( + "time" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" +) + +type Sign struct{} + +func init() { + commands.Register(Sign{}) +} + +func (Sign) Description() string { + return "Sign the message using the account default key." +} + +func (Sign) Context() commands.CommandContext { + return commands.COMPOSE_EDIT | commands.COMPOSE_REVIEW +} + +func (Sign) Aliases() []string { + return []string{"sign"} +} + +func (Sign) Execute(args []string) error { + composer, _ := app.SelectedTabContent().(*app.Composer) + + err := composer.SetSign(!composer.Sign()) + if err != nil { + return err + } + + var statusline string + + if composer.Sign() { + statusline = "Message will be signed." + } else { + statusline = "Message will not be signed." + } + + app.PushStatus(statusline, 10*time.Second) + + return nil +} diff --git a/commands/compose/switch.go b/commands/compose/switch.go new file mode 100644 index 0000000..95f6ad2 --- /dev/null +++ b/commands/compose/switch.go @@ -0,0 +1,70 @@ +package compose + +import ( + "errors" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" +) + +type AccountSwitcher interface { + SwitchAccount(*app.AccountView) error +} + +type SwitchAccount struct { + Prev bool `opt:"-p" desc:"Switch to previous account."` + Next bool `opt:"-n" desc:"Switch to next account."` + Account string `opt:"account" required:"false" complete:"CompleteAccount" desc:"Account name."` +} + +func init() { + commands.Register(SwitchAccount{}) +} + +func (SwitchAccount) Description() string { + return "Change composing from the specified account." +} + +func (SwitchAccount) Context() commands.CommandContext { + return commands.COMPOSE_EDIT | commands.COMPOSE_REVIEW +} + +func (SwitchAccount) Aliases() []string { + return []string{"switch-account"} +} + +func (*SwitchAccount) CompleteAccount(arg string) []string { + return commands.FilterList(app.AccountNames(), arg, nil) +} + +func (s SwitchAccount) Execute(args []string) error { + if !s.Prev && !s.Next && s.Account == "" { + return errors.New("Usage: switch-account -n | -p | <account-name>") + } + + switcher, ok := app.SelectedTabContent().(AccountSwitcher) + if !ok { + return errors.New("this tab cannot switch accounts") + } + + var acct *app.AccountView + var err error + + switch { + case s.Prev: + acct, err = app.PrevAccount() + case s.Next: + acct, err = app.NextAccount() + default: + acct, err = app.Account(s.Account) + } + if err != nil { + return err + } + if err = switcher.SwitchAccount(acct); err != nil { + return err + } + acct.UpdateStatus() + + return nil +} diff --git a/commands/ct.go b/commands/ct.go new file mode 100644 index 0000000..1ac4641 --- /dev/null +++ b/commands/ct.go @@ -0,0 +1,60 @@ +package commands + +import ( + "errors" + "strconv" + "strings" + + "git.sr.ht/~rjarry/aerc/app" +) + +type ChangeTab struct { + Tab string `opt:"tab" complete:"CompleteTab" desc:"Tab name."` +} + +func init() { + Register(ChangeTab{}) +} + +func (ChangeTab) Description() string { + return "Change the focus to the specified tab." +} + +func (ChangeTab) Context() CommandContext { + return GLOBAL +} + +func (ChangeTab) Aliases() []string { + return []string{"ct", "change-tab"} +} + +func (*ChangeTab) CompleteTab(arg string) []string { + return FilterList(app.TabNames(), arg, nil) +} + +func (c ChangeTab) Execute(args []string) error { + if c.Tab == "-" { + ok := app.SelectPreviousTab() + if !ok { + return errors.New("No previous tab to return to") + } + } else { + n, err := strconv.Atoi(c.Tab) + if err == nil { + if strings.HasPrefix(c.Tab, "+") || strings.HasPrefix(c.Tab, "-") { + app.SelectTabAtOffset(n) + } else { + ok := app.SelectTabIndex(n) + if !ok { + return errors.New("No tab with that index") + } + } + } else { + ok := app.SelectTab(c.Tab) + if !ok { + return errors.New("No tab with that name") + } + } + } + return nil +} diff --git a/commands/echo.go b/commands/echo.go new file mode 100644 index 0000000..819b30d --- /dev/null +++ b/commands/echo.go @@ -0,0 +1,30 @@ +package commands + +import ( + "git.sr.ht/~rjarry/aerc/app" +) + +type Echo struct { + Template string `opt:"..." required:"false"` +} + +func init() { + Register(Echo{}) +} + +func (Echo) Description() string { + return "Print text after template expansion." +} + +func (Echo) Aliases() []string { + return []string{"echo"} +} + +func (Echo) Context() CommandContext { + return GLOBAL +} + +func (e Echo) Execute(args []string) error { + app.PushSuccess(e.Template) + return nil +} diff --git a/commands/eml.go b/commands/eml.go new file mode 100644 index 0000000..91d881f --- /dev/null +++ b/commands/eml.go @@ -0,0 +1,93 @@ +package commands + +import ( + "bytes" + "fmt" + "io" + "os" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/lib" + "git.sr.ht/~rjarry/aerc/lib/xdg" +) + +type Eml struct { + Path string `opt:"path" required:"false" complete:"CompletePath" desc:"EML file path."` +} + +func init() { + Register(Eml{}) +} + +func (Eml) Description() string { + return "Open an eml file into the message viewer." +} + +func (Eml) Context() CommandContext { + return GLOBAL +} + +func (Eml) Aliases() []string { + return []string{"eml", "preview"} +} + +func (*Eml) CompletePath(arg string) []string { + return CompletePath(arg, false) +} + +func (e Eml) Execute(args []string) error { + acct := app.SelectedAccount() + if acct == nil { + return fmt.Errorf("no account selected") + } + + showEml := func(r io.Reader) { + data, err := io.ReadAll(r) + if err != nil { + app.PushError(err.Error()) + return + } + lib.NewEmlMessageView(data, app.CryptoProvider(), app.DecryptKeys, + func(view lib.MessageView, err error) { + if err != nil { + app.PushError(err.Error()) + return + } + msgView, err := app.NewMessageViewer(acct, view) + if err != nil { + app.PushError(err.Error()) + return + } + app.NewTab(msgView, + view.MessageInfo().Envelope.Subject) + }) + } + + if e.Path == "" { + switch tab := app.SelectedTabContent().(type) { + case *app.MessageViewer: + part := tab.SelectedMessagePart() + tab.MessageView().FetchBodyPart(part.Index, showEml) + case *app.Composer: + var buf bytes.Buffer + h, err := tab.PrepareHeader() + if err != nil { + return err + } + if err := tab.WriteMessage(h, &buf); err != nil { + return err + } + showEml(&buf) + default: + return fmt.Errorf("unsupported operation") + } + } else { + f, err := os.Open(xdg.ExpandHome(e.Path)) + if err != nil { + return err + } + defer f.Close() + showEml(f) + } + return nil +} diff --git a/commands/exec.go b/commands/exec.go new file mode 100644 index 0000000..5b7d7f6 --- /dev/null +++ b/commands/exec.go @@ -0,0 +1,68 @@ +package commands + +import ( + "fmt" + "os" + "os/exec" + "time" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/lib/log" +) + +type ExecCmd struct { + Args []string `opt:"..."` +} + +func init() { + Register(ExecCmd{}) +} + +func (ExecCmd) Description() string { + return "Execute an arbitrary command in the background." +} + +func (ExecCmd) Context() CommandContext { + return GLOBAL +} + +func (ExecCmd) Aliases() []string { + return []string{"exec"} +} + +func (e ExecCmd) Execute(args []string) error { + cmd := exec.Command(e.Args[0], e.Args[1:]...) + env := os.Environ() + + switch view := app.SelectedTabContent().(type) { + case *app.AccountView: + env = append(env, fmt.Sprintf("account=%s", view.AccountConfig().Name)) + env = append(env, fmt.Sprintf("folder=%s", view.Directories().Selected())) + case *app.MessageViewer: + acct := view.SelectedAccount() + env = append(env, fmt.Sprintf("account=%s", acct.AccountConfig().Name)) + env = append(env, fmt.Sprintf("folder=%s", acct.Directories().Selected())) + } + + cmd.Env = env + + go func() { + defer log.PanicHandler() + + err := cmd.Run() + if err != nil { + app.PushError(err.Error()) + } else { + if cmd.ProcessState.ExitCode() != 0 { + app.PushError(fmt.Sprintf( + "%s: completed with status %d", args[0], + cmd.ProcessState.ExitCode())) + } else { + app.PushStatus(fmt.Sprintf( + "%s: completed with status %d", args[0], + cmd.ProcessState.ExitCode()), 10*time.Second) + } + } + }() + return nil +} diff --git a/commands/help.go b/commands/help.go new file mode 100644 index 0000000..30688c5 --- /dev/null +++ b/commands/help.go @@ -0,0 +1,81 @@ +package commands + +import ( + "fmt" + + "git.sr.ht/~rjarry/aerc/app" +) + +type Help struct { + Topic string `opt:"topic" action:"ParseTopic" default:"aerc" complete:"CompleteTopic" desc:"Help topic."` +} + +var pages = []string{ + "aerc", + "accounts", + "binds", + "config", + "imap", + "jmap", + "notmuch", + "search", + "sendmail", + "smtp", + "stylesets", + "templates", + "tutorial", + "patch", + "keys", +} + +func init() { + Register(Help{}) +} + +func (Help) Description() string { + return "Display one of aerc's man pages in the embedded terminal." +} + +func (Help) Context() CommandContext { + return GLOBAL +} + +func (Help) Aliases() []string { + return []string{"help", "man"} +} + +func (*Help) CompleteTopic(arg string) []string { + return FilterList(pages, arg, nil) +} + +func (h *Help) ParseTopic(arg string) error { + for _, page := range pages { + if arg == page { + if arg != "aerc" { + arg = "aerc-" + arg + } + h.Topic = arg + return nil + } + } + return fmt.Errorf("unknown topic %q", arg) +} + +func (h Help) Execute(args []string) error { + if h.Topic == "aerc-keys" { + app.AddDialog(app.DefaultDialog( + app.NewListBox( + "Bindings: Press <Esc> or <Enter> to close. "+ + "Start typing to filter bindings.", + app.HumanReadableBindings(), + app.SelectedAccountUiConfig(), + func(_ string) { + app.CloseDialog() + }, + ), + )) + return nil + } + term := Term{Cmd: []string{"man", h.Topic}} + return term.Execute(args) +} diff --git a/commands/history.go b/commands/history.go new file mode 100644 index 0000000..576c04b --- /dev/null +++ b/commands/history.go @@ -0,0 +1,140 @@ +package commands + +import ( + "bufio" + "bytes" + "fmt" + "io" + "os" + "sync" + + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/xdg" +) + +type cmdHistory struct { + // rolling buffer of prior commands + // + // most recent command is at the end of the list, + // least recent is index 0 + cmdList []string + + // current placement in list + current int + + // initialize history storage + initHistfile sync.Once + histfile io.ReadWriter +} + +// number of commands to keep in history +const cmdLimit = 1000 + +// CmdHistory is the history of executed commands +var CmdHistory = cmdHistory{} + +func (h *cmdHistory) Add(cmd string) { + h.initHistfile.Do(h.initialize) + + // if we're at cap, cut off the first element + if len(h.cmdList) >= cmdLimit { + h.cmdList = h.cmdList[1:] + } + + if len(h.cmdList) == 0 || h.cmdList[len(h.cmdList)-1] != cmd { + h.cmdList = append(h.cmdList, cmd) + + h.writeHistory() + } + + // whenever we add a new command, reset the current + // pointer to the "beginning" of the list + h.Reset() +} + +// Prev returns the previous command in history. +// Since the list is reverse-order, this will return elements +// increasingly towards index 0. +func (h *cmdHistory) Prev() string { + h.initHistfile.Do(h.initialize) + + if h.current <= 0 || len(h.cmdList) == 0 { + h.current = -1 + return "(Already at beginning)" + } + h.current-- + + return h.cmdList[h.current] +} + +// Next returns the next command in history. +// Since the list is reverse-order, this will return elements +// increasingly towards index len(cmdList). +func (h *cmdHistory) Next() string { + h.initHistfile.Do(h.initialize) + + if h.current >= len(h.cmdList)-1 || len(h.cmdList) == 0 { + h.current = len(h.cmdList) + return "(Already at end)" + } + h.current++ + + return h.cmdList[h.current] +} + +// Reset the current pointer to the beginning of history. +func (h *cmdHistory) Reset() { + h.current = len(h.cmdList) +} + +func (h *cmdHistory) initialize() { + var err error + openFlags := os.O_RDWR | os.O_EXCL + + histPath := xdg.StatePath("aerc", "history") + if _, err := os.Stat(histPath); os.IsNotExist(err) { + _ = os.MkdirAll(xdg.StatePath("aerc"), 0o700) // caught by OpenFile + openFlags |= os.O_CREATE + } + + // O_EXCL to make sure that only one aerc writes to the file + h.histfile, err = os.OpenFile( + histPath, + openFlags, + 0o600, + ) + if err != nil { + log.Errorf("failed to open history file: %v", err) + // basically mirror the old behavior + h.histfile = bytes.NewBuffer([]byte{}) + return + } + + s := bufio.NewScanner(h.histfile) + + for s.Scan() { + h.cmdList = append(h.cmdList, s.Text()) + } + + h.Reset() +} + +func (h *cmdHistory) writeHistory() { + if fh, ok := h.histfile.(*os.File); ok { + err := fh.Truncate(0) + if err != nil { + // if we can't delete it, don't break it. + return + } + _, err = fh.Seek(0, io.SeekStart) + if err != nil { + // if we can't delete it, don't break it. + return + } + for _, entry := range h.cmdList { + fmt.Fprintln(fh, entry) + } + + fh.Sync() //nolint:errcheck // if your computer can't sync you're in bigger trouble + } +} diff --git a/commands/menu.go b/commands/menu.go new file mode 100644 index 0000000..4908a2a --- /dev/null +++ b/commands/menu.go @@ -0,0 +1,232 @@ +package commands + +import ( + "errors" + "io" + "os" + "os/exec" + "strings" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/ui" + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/go-opt/v2" +) + +type Menu struct { + ErrExit bool `opt:"-e" desc:"Stop executing commands on the first error."` + Background bool `opt:"-b" desc:"Do NOT spawn the popover dialog."` + Accounts bool `opt:"-a" desc:"Feed command with account names."` + Directories bool `opt:"-d" desc:"Feed command with folder names."` + Command string `opt:"-c" desc:"Override [general].default-menu-cmd."` + Xargs string `opt:"..." complete:"CompleteXargs" desc:"Command name."` +} + +func init() { + Register(Menu{}) +} + +func (Menu) Description() string { + return "Open a popover dialog." +} + +func (Menu) Context() CommandContext { + return GLOBAL +} + +func (Menu) Aliases() []string { + return []string{"menu"} +} + +func (*Menu) CompleteXargs(arg string) []string { + return FilterList(ActiveCommandNames(), arg, nil) +} + +func (m Menu) Execute([]string) error { + if m.Command == "" { + m.Command = config.General.DefaultMenuCmd + } + useFallback := m.useFallback() + if m.Background && useFallback { + return errors.New("Either -c <command> or " + + "default-menu-cmd is required to run " + + "in the background.") + } + if _, _, err := ResolveCommand(m.Xargs, nil, nil); err != nil { + return err + } + + lines, err := m.feedLines() + if err != nil { + return err + } + + title := " :" + strings.TrimLeft(m.Xargs, ": \t") + " ... " + + if useFallback { + return m.fallback(title, lines) + } + + pick, err := os.CreateTemp("", "aerc-menu-*") + if err != nil { + return err + } + + var proc *exec.Cmd + if strings.Contains(m.Command, "%f") { + proc = exec.Command("sh", "-c", + strings.ReplaceAll(m.Command, "%f", opt.QuoteArg(pick.Name()))) + } else { + proc = exec.Command("sh", "-c", m.Command+" >&3") + proc.ExtraFiles = append(proc.ExtraFiles, pick) + } + if len(lines) > 0 { + proc.Stdin = strings.NewReader(strings.Join(lines, "\n")) + } + + xargs := func(err error) { + var buf []byte + if err == nil { + _, err = pick.Seek(0, io.SeekStart) + } + if err == nil { + buf, err = io.ReadAll(pick) + } + pick.Close() + os.Remove(pick.Name()) + if err != nil { + app.PushError("command failed: " + err.Error()) + return + } + if len(buf) == 0 { + return + } + m.runCmd(string(buf)) + } + + if m.Background { + go func() { + defer log.PanicHandler() + xargs(proc.Run()) + }() + } else { + term, err := app.NewTerminal(proc) + if err != nil { + return err + } + term.Focus(true) + term.OnClose = func(err error) { + app.CloseDialog() + xargs(err) + } + + widget := ui.NewBox(term, title, "", app.SelectedAccountUiConfig()) + app.AddDialog(app.DefaultDialog(widget)) + } + + return nil +} + +func (m Menu) useFallback() bool { + if m.Command == "" || m.Command == "-" { + warnMsg := "no command provided, falling back on aerc's picker." + log.Warnf(warnMsg) + app.PushWarning(warnMsg) + return true + } + cmd, _, _ := strings.Cut(m.Command, " ") + _, err := exec.LookPath(cmd) + if err != nil { + warnMsg := "command '" + cmd + "' not found in PATH, " + + "falling back on aerc's picker." + log.Warnf(warnMsg) + app.PushWarning(warnMsg) + return true + } + return false +} + +func (m Menu) runCmd(buffer string) { + var ( + cmd Command + cmdline string + err error + ) + + for _, line := range strings.Split(buffer, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + cmdline = m.Xargs + " " + line + cmdline, cmd, err = ResolveCommand(cmdline, nil, nil) + if err == nil { + err = ExecuteCommand(cmd, cmdline) + } + if err != nil { + app.PushError(m.Xargs + ": " + err.Error()) + if m.ErrExit { + return + } + } + } +} + +func (m Menu) fallback(title string, lines []string) error { + listBox := app.NewListBox( + title, lines, app.SelectedAccountUiConfig(), + func(line string) { + app.CloseDialog() + if line == "" { + return + } + m.runCmd(line) + }) + listBox.SetTextFilter(func(list []string, term string) []string { + return FilterList(list, term, func(s string) string { return s }) + }) + widget := ui.NewBox(listBox, "", "", app.SelectedAccountUiConfig()) + app.AddDialog(app.DefaultDialog(widget)) + return nil +} + +func (m Menu) feedLines() ([]string, error) { + var lines []string + + switch { + case m.Accounts && m.Directories: + for _, a := range app.AccountNames() { + account, _ := app.Account(a) + a = opt.QuoteArg(a) + for _, d := range account.Directories().List() { + dir := account.Directories().Directory(d) + if dir != nil && dir.Role != models.QueryRole { + d = opt.QuoteArg(d) + } + lines = append(lines, a+" "+d) + } + } + + case m.Accounts: + for _, account := range app.AccountNames() { + lines = append(lines, opt.QuoteArg(account)) + } + + case m.Directories: + account := app.SelectedAccount() + if account == nil { + return nil, errors.New("No account selected.") + } + for _, d := range account.Directories().List() { + dir := account.Directories().Directory(d) + if dir != nil && dir.Role != models.QueryRole { + d = opt.QuoteArg(d) + } + lines = append(lines, d) + } + } + + return lines, nil +} diff --git a/commands/mode/noquit.go b/commands/mode/noquit.go new file mode 100644 index 0000000..92f83ee --- /dev/null +++ b/commands/mode/noquit.go @@ -0,0 +1,23 @@ +package mode + +import "sync/atomic" + +// noquit is a counter for goroutines that requested the no-quit mode +var noquit int32 + +// NoQuit enters no-quit mode where aerc cannot be exited (unless the force +// option is used) +func NoQuit() { + atomic.AddInt32(&noquit, 1) +} + +// NoQuitDone leaves the no-quit mode +func NoQuitDone() { + atomic.AddInt32(&noquit, -1) +} + +// QuitAllowed checks if aerc can exit normally (only when all goroutines that +// requested a no-quit mode were done and called the NoQuitDone() function) +func QuitAllowed() bool { + return atomic.LoadInt32(&noquit) <= 0 +} diff --git a/commands/move-tab.go b/commands/move-tab.go new file mode 100644 index 0000000..a1ebfc0 --- /dev/null +++ b/commands/move-tab.go @@ -0,0 +1,46 @@ +package commands + +import ( + "strconv" + "strings" + + "git.sr.ht/~rjarry/aerc/app" +) + +type MoveTab struct { + Index int `opt:"index" metavar:"[+|-]<index>" action:"ParseIndex"` + Relative bool +} + +func init() { + Register(MoveTab{}) +} + +func (MoveTab) Description() string { + return "Move the selected tab to the given index." +} + +func (MoveTab) Context() CommandContext { + return GLOBAL +} + +func (m *MoveTab) ParseIndex(arg string) error { + i, err := strconv.ParseInt(arg, 10, 64) + if err != nil { + return err + } + m.Index = int(i) + if strings.HasPrefix(arg, "+") || strings.HasPrefix(arg, "-") { + m.Relative = true + } + return nil +} + +func (MoveTab) Aliases() []string { + return []string{"move-tab"} +} + +func (m MoveTab) Execute(args []string) error { + app.MoveTab(m.Index, m.Relative) + return nil +} diff --git a/commands/msg/archive.go b/commands/msg/archive.go new file mode 100644 index 0000000..eb143b3 --- /dev/null +++ b/commands/msg/archive.go @@ -0,0 +1,178 @@ +package msg + +import ( + "fmt" + "strings" + "sync" + "time" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/types" +) + +const ( + ARCHIVE_FLAT = "flat" + ARCHIVE_YEAR = "year" + ARCHIVE_MONTH = "month" +) + +var ARCHIVE_TYPES = []string{ARCHIVE_FLAT, ARCHIVE_YEAR, ARCHIVE_MONTH} + +type Archive struct { + MultiFileStrategy *types.MultiFileStrategy `opt:"-m" action:"ParseMFS" complete:"CompleteMFS" desc:"Multi-file strategy."` + Type string `opt:"type" action:"ParseArchiveType" metavar:"flat|year|month" complete:"CompleteType" desc:"Archiving scheme."` +} + +func (a *Archive) ParseMFS(arg string) error { + if arg != "" { + mfs, ok := types.StrToStrategy[arg] + if !ok { + return fmt.Errorf("invalid multi-file strategy %s", arg) + } + a.MultiFileStrategy = &mfs + } + return nil +} + +func (a *Archive) ParseArchiveType(arg string) error { + for _, t := range ARCHIVE_TYPES { + if t == arg { + a.Type = arg + return nil + } + } + return fmt.Errorf("invalid archive type") +} + +func init() { + commands.Register(Archive{}) +} + +func (Archive) Description() string { + return "Move the selected message to the archive." +} + +func (Archive) Context() commands.CommandContext { + return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER +} + +func (Archive) Aliases() []string { + return []string{"archive"} +} + +func (Archive) CompleteMFS(arg string) []string { + return commands.FilterList(types.StrategyStrs(), arg, nil) +} + +func (*Archive) CompleteType(arg string) []string { + return commands.FilterList(ARCHIVE_TYPES, arg, nil) +} + +func (a Archive) Execute(args []string) error { + h := newHelper() + msgs, err := h.messages() + if err != nil { + return err + } + err = archive(msgs, a.MultiFileStrategy, a.Type) + return err +} + +func archive(msgs []*models.MessageInfo, mfs *types.MultiFileStrategy, + archiveType string, +) error { + h := newHelper() + acct, err := h.account() + if err != nil { + return err + } + store, err := h.store() + if err != nil { + return err + } + var uids []models.UID + for _, msg := range msgs { + uids = append(uids, msg.Uid) + } + archiveDir := acct.AccountConfig().Archive + marker := store.Marker() + marker.ClearVisualMark() + next := findNextNonDeleted(uids, store) + + var uidMap map[string][]models.UID + switch archiveType { + case ARCHIVE_MONTH: + uidMap = groupBy(msgs, func(msg *models.MessageInfo) string { + dir := strings.Join([]string{ + archiveDir, + fmt.Sprintf("%d", msg.Envelope.Date.Year()), + fmt.Sprintf("%02d", msg.Envelope.Date.Month()), + }, app.SelectedAccount().Worker().PathSeparator(), + ) + return dir + }) + case ARCHIVE_YEAR: + uidMap = groupBy(msgs, func(msg *models.MessageInfo) string { + dir := strings.Join([]string{ + archiveDir, + fmt.Sprintf("%v", msg.Envelope.Date.Year()), + }, app.SelectedAccount().Worker().PathSeparator(), + ) + return dir + }) + case ARCHIVE_FLAT: + uidMap = make(map[string][]models.UID) + uidMap[archiveDir] = commands.UidsFromMessageInfos(msgs) + } + + var wg sync.WaitGroup + wg.Add(len(uidMap)) + success := true + + for dir, uids := range uidMap { + store.Move(uids, dir, true, mfs, func( + msg types.WorkerMessage, + ) { + switch msg := msg.(type) { + case *types.Done: + wg.Done() + case *types.Error: + app.PushError(msg.Error.Error()) + success = false + wg.Done() + marker.Remark() + } + }) + } + // we need to do that in the background, else we block the main thread + go func() { + defer log.PanicHandler() + + wg.Wait() + if success { + var s string + if len(uids) > 1 { + s = "%d messages archived to %s" + } else { + s = "%d message archived to %s" + } + app.PushStatus(fmt.Sprintf(s, len(uids), archiveDir), 10*time.Second) + handleDone(acct, next, store) + } + }() + return nil +} + +func groupBy(msgs []*models.MessageInfo, + grouper func(*models.MessageInfo) string, +) map[string][]models.UID { + m := make(map[string][]models.UID) + for _, msg := range msgs { + group := grouper(msg) + m[group] = append(m[group], msg.Uid) + } + return m +} diff --git a/commands/msg/bounce.go b/commands/msg/bounce.go new file mode 100644 index 0000000..3b093ab --- /dev/null +++ b/commands/msg/bounce.go @@ -0,0 +1,214 @@ +package msg + +import ( + "fmt" + "io" + "net/url" + "strings" + "time" + + "github.com/emersion/go-message/mail" + "github.com/pkg/errors" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/commands/mode" + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/send" + "git.sr.ht/~rjarry/aerc/worker/types" +) + +type Bounce struct { + Account string `opt:"-A" complete:"CompleteAccount" desc:"Account from which to re-send the message."` + To []string `opt:"..." required:"true" complete:"CompleteTo" desc:"Recipient from address book."` +} + +func init() { + commands.Register(Bounce{}) +} + +func (Bounce) Description() string { + return "Re-send the selected message(s) to the specified addresses." +} + +func (Bounce) Aliases() []string { + return []string{"bounce", "resend"} +} + +func (*Bounce) CompleteAccount(arg string) []string { + return commands.FilterList(app.AccountNames(), arg, commands.QuoteSpace) +} + +func (*Bounce) CompleteTo(arg string) []string { + return commands.FilterList(commands.GetAddress(arg), arg, commands.QuoteSpace) +} + +func (Bounce) Context() commands.CommandContext { + return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER +} + +func (b Bounce) Execute(args []string) error { + if len(b.To) == 0 { + return errors.New("No recipients specified") + } + addresses := strings.Join(b.To, ", ") + + app.PushStatus("Bouncing to "+addresses, 10*time.Second) + + widget := app.SelectedTabContent().(app.ProvidesMessage) + + var err error + acct := widget.SelectedAccount() + if b.Account != "" { + acct, err = app.Account(b.Account) + } + switch { + case err != nil: + return fmt.Errorf("Failed to select account %q: %w", b.Account, err) + case acct == nil: + return errors.New("No account selected") + } + + store := widget.Store() + if store == nil { + return errors.New("Cannot perform action. Messages still loading") + } + + config := acct.AccountConfig() + + outgoing, err := config.Outgoing.ConnectionString() + if err != nil { + return errors.Wrap(err, "ReadCredentials()") + } + if outgoing == "" { + return errors.New("No outgoing mail transport configured for this account") + } + uri, err := url.Parse(outgoing) + if err != nil { + return errors.Wrap(err, "url.Parse()") + } + + rcpts, err := mail.ParseAddressList(addresses) + if err != nil { + return errors.Wrap(err, "ParseAddressList()") + } + + var domain string + if domain_, ok := config.Params["smtp-domain"]; ok { + domain = domain_ + } + + hostname, err := send.GetMessageIdHostname(config.SendWithHostname, config.From) + if err != nil { + return errors.Wrap(err, "GetMessageIdHostname()") + } + + // According to RFC2822, all of the resent fields corresponding + // to a particular resending of the message SHOULD be together. + // Each new set of resent fields is prepended to the message; + // that is, the most recent set of resent fields appear earlier in the + // message. + headers := fmt.Sprintf("Resent-From: %s\r\n", config.From) + headers += "Resent-Date: %s\r\n" + headers += "Resent-Message-ID: <%s>\r\n" + headers += fmt.Sprintf("Resent-To: %s\r\n", addresses) + + helper := newHelper() + uids, err := helper.markedOrSelectedUids() + if err != nil { + return err + } + + mode.NoQuit() + + marker := store.Marker() + marker.ClearVisualMark() + + errCh := make(chan error) + store.FetchFull(uids, func(fm *types.FullMessage) { + defer log.PanicHandler() + + var header mail.Header + var msgId string + var err, errClose error + + uid := fm.Content.Uid + msg := store.Messages[uid] + if msg == nil { + errCh <- fmt.Errorf("no message info: %v", uid) + return + } + if err = header.GenerateMessageIDWithHostname(hostname); err != nil { + errCh <- errors.Wrap(err, "GenerateMessageIDWithHostname()") + return + } + if msgId, err = header.MessageID(); err != nil { + errCh <- errors.Wrap(err, "MessageID()") + return + } + reader := strings.NewReader(fmt.Sprintf(headers, + time.Now().Format(time.RFC1123Z), msgId)) + + go func() { + defer log.PanicHandler() + defer func() { errCh <- err }() + + var sender io.WriteCloser + + log.Debugf("Bouncing email <%s> to %s", + msg.Envelope.MessageId, addresses) + + if sender, err = send.NewSender(acct.Worker(), uri, + domain, config.From, rcpts, nil); err != nil { + return + } + defer func() { + errClose = sender.Close() + // If there has already been an error, + // we don't want to clobber it. + if err == nil { + err = errClose + } else if errClose != nil { + app.PushError(errClose.Error()) + } + }() + if _, err = io.Copy(sender, reader); err != nil { + return + } + _, err = io.Copy(sender, fm.Content.Reader) + }() + }) + + go func() { + defer log.PanicHandler() + defer mode.NoQuitDone() + + var total, success int + + for err = range errCh { + if err != nil { + app.PushError(err.Error()) + } else { + success++ + } + total++ + if total == len(uids) { + break + } + } + if success != total { + marker.Remark() + app.PushError(fmt.Sprintf("Failed to bounce %d of the messages", + total-success)) + } else { + plural := "" + if success > 1 { + plural = "s" + } + app.PushStatus(fmt.Sprintf("Bounced %d message%s", + success, plural), 10*time.Second) + } + }() + + return nil +} diff --git a/commands/msg/copy.go b/commands/msg/copy.go new file mode 100644 index 0000000..ffc6a34 --- /dev/null +++ b/commands/msg/copy.go @@ -0,0 +1,201 @@ +package msg + +import ( + "bytes" + "fmt" + "time" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/lib" + cryptoutil "git.sr.ht/~rjarry/aerc/lib/crypto/util" + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/types" + "github.com/emersion/go-message/mail" + "github.com/pkg/errors" +) + +type Copy struct { + CreateFolders bool `opt:"-p" desc:"Create folder if it does not exist."` + Decrypt bool `opt:"-d" desc:"Decrypt the message before copying."` + Account string `opt:"-a" complete:"CompleteAccount" desc:"Copy to the specified account."` + MultiFileStrategy *types.MultiFileStrategy `opt:"-m" action:"ParseMFS" complete:"CompleteMFS" desc:"Multi-file strategy."` + Folder string `opt:"folder" complete:"CompleteFolder" desc:"Target folder."` +} + +func init() { + commands.Register(Copy{}) +} + +func (Copy) Description() string { + return "Copy the selected message(s) to the specified folder." +} + +func (Copy) Context() commands.CommandContext { + return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER +} + +func (Copy) Aliases() []string { + return []string{"cp", "copy"} +} + +func (c *Copy) ParseMFS(arg string) error { + if arg != "" { + mfs, ok := types.StrToStrategy[arg] + if !ok { + return fmt.Errorf("invalid multi-file strategy %s", arg) + } + c.MultiFileStrategy = &mfs + } + return nil +} + +func (*Copy) CompleteAccount(arg string) []string { + return commands.FilterList(app.AccountNames(), arg, commands.QuoteSpace) +} + +func (c *Copy) CompleteFolder(arg string) []string { + var acct *app.AccountView + if len(c.Account) > 0 { + acct, _ = app.Account(c.Account) + } else { + acct = app.SelectedAccount() + } + if acct == nil { + return nil + } + return commands.FilterList(acct.Directories().List(), arg, nil) +} + +func (Copy) CompleteMFS(arg string) []string { + return commands.FilterList(types.StrategyStrs(), arg, nil) +} + +func (c Copy) Execute(args []string) error { + h := newHelper() + uids, err := h.markedOrSelectedUids() + if err != nil { + return err + } + store, err := h.store() + if err != nil { + return err + } + + // when the decrypt flag is set, add the current account to c.Account to + // ensure that we do not take the store.Copy route. + if c.Decrypt { + if acct := app.SelectedAccount(); acct != nil { + c.Account = acct.Name() + } else { + return errors.New("no account name found") + } + } + + if len(c.Account) == 0 { + store.Copy(uids, c.Folder, c.CreateFolders, c.MultiFileStrategy, + func(msg types.WorkerMessage) { + c.CallBack(msg, uids, store) + }) + return nil + } + + destAcct, err := app.Account(c.Account) + if err != nil { + return err + } + + destStore := destAcct.Store() + if destStore == nil { + app.PushError(fmt.Sprintf("No message store in %s", c.Account)) + return nil + } + + var messages []*types.FullMessage + fetchDone := make(chan bool, 1) + store.FetchFull(uids, func(fm *types.FullMessage) { + if fm == nil { + return + } + + if c.Decrypt { + h := new(mail.Header) + msg, ok := store.Messages[fm.Content.Uid] + if ok { + h = msg.RFC822Headers + } + cleartext, err := cryptoutil.Cleartext(fm.Content.Reader, *h) + if err != nil { + log.Debugf("could not decrypt message %v", fm.Content.Uid) + } else { + fm.Content.Reader = bytes.NewReader(cleartext) + } + } + + messages = append(messages, fm) + if len(messages) == len(uids) { + fetchDone <- true + } + }) + + // Since this operation can take some time with some backends + // (e.g. IMAP), provide some feedback to inform the user that + // something is happening + app.PushStatus("Copying messages...", 10*time.Second) + go func() { + defer log.PanicHandler() + + select { + case <-fetchDone: + break + case <-time.After(30 * time.Second): + // TODO: find a better way to determine if store.FetchFull() + // has finished with some errors. + app.PushError("Failed to fetch all messages") + if len(messages) == 0 { + return + } + } + for _, fm := range messages { + buf := new(bytes.Buffer) + _, err = buf.ReadFrom(fm.Content.Reader) + if err != nil { + log.Warnf("failed to read message: %v", err) + continue + } + destStore.Append( + c.Folder, + models.SeenFlag, + time.Now(), + buf, + buf.Len(), + func(msg types.WorkerMessage) { + c.CallBack(msg, uids, store) + }, + ) + } + }() + return nil +} + +func (c Copy) CallBack(msg types.WorkerMessage, uids []models.UID, store *lib.MessageStore) { + dest := c.Folder + if len(c.Account) != 0 { + dest = fmt.Sprintf("%s in %s", c.Folder, c.Account) + } + + switch msg := msg.(type) { + case *types.Done: + var s string + if len(uids) > 1 { + s = "%d messages copied to %s" + } else { + s = "%d message copied to %s" + } + app.PushStatus(fmt.Sprintf(s, len(uids), dest), 10*time.Second) + store.Marker().ClearVisualMark() + case *types.Error: + app.PushError(msg.Error.Error()) + } +} diff --git a/commands/msg/delete.go b/commands/msg/delete.go new file mode 100644 index 0000000..dcfc5ea --- /dev/null +++ b/commands/msg/delete.go @@ -0,0 +1,164 @@ +package msg + +import ( + "fmt" + "time" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/lib" + "git.sr.ht/~rjarry/aerc/lib/ui" + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/types" +) + +type Delete struct { + MultiFileStrategy *types.MultiFileStrategy `opt:"-m" action:"ParseMFS" complete:"CompleteMFS" desc:"Multi-file strategy."` +} + +func init() { + commands.Register(Delete{}) +} + +func (Delete) Description() string { + return "Delete the selected message(s)." +} + +func (Delete) Context() commands.CommandContext { + return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER +} + +func (Delete) Aliases() []string { + return []string{"delete", "delete-message"} +} + +func (d *Delete) ParseMFS(arg string) error { + if arg != "" { + mfs, ok := types.StrToStrategy[arg] + if !ok { + return fmt.Errorf("invalid multi-file strategy %s", arg) + } + d.MultiFileStrategy = &mfs + } + return nil +} + +func (Delete) CompleteMFS(arg string) []string { + return commands.FilterList(types.StrategyStrs(), arg, nil) +} + +func (d Delete) Execute(args []string) error { + h := newHelper() + store, err := h.store() + if err != nil { + return err + } + uids, err := h.markedOrSelectedUids() + if err != nil { + return err + } + acct, err := h.account() + if err != nil { + return err + } + sel := store.Selected() + marker := store.Marker() + marker.ClearVisualMark() + // caution, can be nil + next := findNextNonDeleted(uids, store) + store.Delete(uids, d.MultiFileStrategy, func(msg types.WorkerMessage) { + switch msg := msg.(type) { + case *types.Done: + var s string + if len(uids) > 1 { + s = "%d messages deleted" + } else { + s = "%d message deleted" + } + app.PushStatus(fmt.Sprintf(s, len(uids)), 10*time.Second) + mv, isMsgView := h.msgProvider.(*app.MessageViewer) + if isMsgView { + if !config.Ui.NextMessageOnDelete { + app.RemoveTab(h.msgProvider, true) + } else { + // no more messages in the list + if next == nil { + app.RemoveTab(h.msgProvider, true) + acct.Messages().Select(-1) + ui.Invalidate() + return + } + lib.NewMessageStoreView(next, mv.MessageView().SeenFlagSet(), + store, app.CryptoProvider(), app.DecryptKeys, + func(view lib.MessageView, err error) { + if err != nil { + app.PushError(err.Error()) + return + } + nextMv, err := app.NewMessageViewer(acct, view) + if err != nil { + app.PushError(err.Error()) + return + } + app.ReplaceTab(mv, nextMv, next.Envelope.Subject, true) + }) + } + } else { + if next == nil { + // We deleted the last message, select the new last message + // instead of the first message + acct.Messages().Select(-1) + } + } + case *types.Error: + marker.Remark() + store.Select(sel.Uid) + app.PushError(msg.Error.Error()) + case *types.Unsupported: + marker.Remark() + store.Select(sel.Uid) + // notmuch doesn't support it, we want the user to know + app.PushError(" error, unsupported for this worker") + } + }) + return nil +} + +func findNextNonDeleted(deleted []models.UID, store *lib.MessageStore) *models.MessageInfo { + var next, previous *models.MessageInfo + stepper := []func(){store.Next, store.Prev} + for _, stepFn := range stepper { + previous = nil + for { + next = store.Selected() + if next != nil && !contains(deleted, next.Uid) { + if _, deleted := store.Deleted[next.Uid]; !deleted { + return next + } + } + if next == nil || previous == next { + // If previous == next, this is the last + // message. Set next to nil either way + next = nil + break + } + stepFn() + previous = next + } + } + + if next != nil { + store.Select(next.Uid) + } + return next +} + +func contains(uids []models.UID, uid models.UID) bool { + for _, item := range uids { + if item == uid { + return true + } + } + return false +} diff --git a/commands/msg/envelope.go b/commands/msg/envelope.go new file mode 100644 index 0000000..d064e43 --- /dev/null +++ b/commands/msg/envelope.go @@ -0,0 +1,134 @@ +package msg + +import ( + "errors" + "fmt" + "strings" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/lib/format" + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/models" + "github.com/emersion/go-message/mail" +) + +type Envelope struct { + Header bool `opt:"-h" desc:"Show all header fields."` + Format string `opt:"-s" default:"%-20.20s: %s" desc:"Format specifier."` +} + +func init() { + commands.Register(Envelope{}) +} + +func (Envelope) Description() string { + return "Open the message envelope in a dialog popup." +} + +func (Envelope) Context() commands.CommandContext { + return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER +} + +func (Envelope) Aliases() []string { + return []string{"envelope"} +} + +func (e Envelope) Execute(args []string) error { + provider, ok := app.SelectedTabContent().(app.ProvidesMessages) + if !ok { + return fmt.Errorf("current tab does not implement app.ProvidesMessage interface") + } + + acct := provider.SelectedAccount() + if acct == nil { + return errors.New("No account selected") + } + + var list []string + if msg, err := provider.SelectedMessage(); err != nil { + return err + } else { + if msg != nil { + if e.Header { + list = parseHeader(msg, e.Format) + } else { + list = parseEnvelope(msg, e.Format, + acct.UiConfig().TimestampFormat) + } + } else { + return fmt.Errorf("Selected message is empty.") + } + } + + app.AddDialog(app.DefaultDialog( + app.NewListBox( + "Message Envelope. Press <Esc> or <Enter> to close. "+ + "Start typing to filter.", + list, + app.SelectedAccountUiConfig(), + func(_ string) { + app.CloseDialog() + }, + ), + )) + + return nil +} + +func parseEnvelope(msg *models.MessageInfo, fmtStr, fmtTime string, +) (result []string) { + if envlp := msg.Envelope; envlp != nil { + addStr := func(key, text string) { + result = append(result, fmt.Sprintf(fmtStr, key, text)) + } + addAddr := func(key string, ls []*mail.Address) { + for _, l := range ls { + result = append(result, + fmt.Sprintf(fmtStr, key, + format.AddressForHumans(l))) + } + } + + addStr("Date", envlp.Date.Format(fmtTime)) + addStr("Subject", envlp.Subject) + addStr("Message-Id", envlp.MessageId) + + addAddr("From", envlp.From) + addAddr("To", envlp.To) + addAddr("ReplyTo", envlp.ReplyTo) + addAddr("Cc", envlp.Cc) + addAddr("Bcc", envlp.Bcc) + } + return +} + +func parseHeader(msg *models.MessageInfo, fmtStr string) (result []string) { + if h := msg.RFC822Headers; h != nil { + hf := h.Fields() + for hf.Next() { + text, err := hf.Text() + if err != nil { + log.Errorf(err.Error()) + text = hf.Value() + } + result = append(result, + headerExpand(fmtStr, hf.Key(), text)...) + } + } + return +} + +func headerExpand(fmtStr, key, text string) []string { + var result []string + switch strings.ToLower(key) { + case "to", "from", "bcc", "cc": + for _, item := range strings.Split(text, ",") { + result = append(result, fmt.Sprintf(fmtStr, key, + strings.TrimSpace(item))) + } + default: + result = append(result, fmt.Sprintf(fmtStr, key, text)) + } + return result +} diff --git a/commands/msg/fold.go b/commands/msg/fold.go new file mode 100644 index 0000000..5f2b4b0 --- /dev/null +++ b/commands/msg/fold.go @@ -0,0 +1,73 @@ +package msg + +import ( + "errors" + + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/lib/ui" +) + +type Fold struct { + All bool `opt:"-a" desc:"Fold/unfold all threads."` + Toggle bool `opt:"-t" desc:"Toggle between folded/unfolded."` +} + +func init() { + commands.Register(Fold{}) +} + +func (Fold) Description() string { + return "Collapse or expand the thread children of the selected message." +} + +func (Fold) Context() commands.CommandContext { + return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER +} + +func (Fold) Aliases() []string { + return []string{"fold", "unfold"} +} + +func (f Fold) Execute(args []string) error { + h := newHelper() + store, err := h.store() + if err != nil { + return err + } + + if f.All { + point := store.SelectedUid() + uids := store.Uids() + for _, uid := range uids { + t, err := store.Thread(uid) + if err == nil && t.Parent == nil { + switch args[0] { + case "fold": + err = store.Fold(uid, f.Toggle) + case "unfold": + err = store.Unfold(uid, f.Toggle) + } + } + if err != nil { + return err + } + } + store.Select(point) + ui.Invalidate() + return err + } + + msg := store.Selected() + if msg == nil { + return errors.New("No message selected") + } + + switch args[0] { + case "fold": + err = store.Fold(msg.Uid, f.Toggle) + case "unfold": + err = store.Unfold(msg.Uid, f.Toggle) + } + ui.Invalidate() + return err +} diff --git a/commands/msg/forward.go b/commands/msg/forward.go new file mode 100644 index 0000000..a314d0f --- /dev/null +++ b/commands/msg/forward.go @@ -0,0 +1,264 @@ +package msg + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "io" + "math/rand" + "os" + "path" + "strings" + "sync" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/lib" + "git.sr.ht/~rjarry/aerc/lib/crypto" + "git.sr.ht/~rjarry/aerc/lib/format" + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/types" + "github.com/emersion/go-message/mail" +) + +type forward struct { + AttachAll bool `opt:"-A" desc:"Forward the message and all attachments."` + AttachFull bool `opt:"-F" desc:"Forward the full message as an RFC 2822 attachment."` + Edit bool `opt:"-e" desc:"Force [compose].edit-headers = true."` + NoEdit bool `opt:"-E" desc:"Force [compose].edit-headers = false."` + Template string `opt:"-T" complete:"CompleteTemplate" desc:"Template name."` + SkipEditor bool `opt:"-s" desc:"Skip the editor and go directly to the review screen."` + To []string `opt:"..." required:"false" complete:"CompleteTo" desc:"Recipient from address book."` +} + +func init() { + commands.Register(forward{}) +} + +func (forward) Description() string { + return "Open the composer to forward the selected message to another recipient." +} + +func (forward) Context() commands.CommandContext { + return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER +} + +func (forward) Aliases() []string { + return []string{"forward"} +} + +func (*forward) CompleteTemplate(arg string) []string { + return commands.GetTemplates(arg) +} + +func (*forward) CompleteTo(arg string) []string { + return commands.GetAddress(arg) +} + +func (f forward) Execute(args []string) error { + if f.AttachAll && f.AttachFull { + return errors.New("Options -A and -F are mutually exclusive") + } + editHeaders := (config.Compose.EditHeaders || f.Edit) && !f.NoEdit + + widget := app.SelectedTabContent().(app.ProvidesMessage) + acct := widget.SelectedAccount() + if acct == nil { + return errors.New("No account selected") + } + msg, err := widget.SelectedMessage() + if err != nil { + return err + } + log.Debugf("Forwarding email <%s>", msg.Envelope.MessageId) + + h := &mail.Header{} + subject := "Fwd: " + msg.Envelope.Subject + h.SetSubject(subject) + + var tolist []*mail.Address + to := strings.Join(f.To, ", ") + if strings.Contains(to, "@") { + tolist, err = mail.ParseAddressList(to) + if err != nil { + return fmt.Errorf("invalid to address(es): %w", err) + } + } + if len(tolist) > 0 { + h.SetAddressList("to", tolist) + } + + original := models.OriginalMail{ + From: format.FormatAddresses(msg.Envelope.From), + Date: msg.Envelope.Date, + RFC822Headers: msg.RFC822Headers, + } + + addTab := func() (*app.Composer, error) { + composer, err := app.NewComposer(acct, + acct.AccountConfig(), acct.Worker(), editHeaders, + f.Template, h, &original, nil) + if err != nil { + app.PushError("Error: " + err.Error()) + return nil, err + } + + composer.Tab = app.NewTab(composer, subject) + switch { + case f.SkipEditor: + composer.Terminal().Close() + case !h.Has("to"): + composer.FocusEditor("to") + default: + composer.FocusTerminal() + } + return composer, nil + } + + mv, isMsgViewer := widget.(*app.MessageViewer) + store := widget.Store() + noStore := store == nil + if noStore && !isMsgViewer { + return errors.New("Cannot perform action. Messages still loading") + } + + if f.AttachFull { + tmpDir, err := os.MkdirTemp("", "aerc-tmp-attachment") + if err != nil { + return err + } + tmpFileName := path.Join(tmpDir, + strings.ReplaceAll(fmt.Sprintf("%s.eml", msg.Envelope.Subject), "/", "-")) + + var fetchFull func(func(io.Reader)) + + if isMsgViewer { + fetchFull = mv.MessageView().FetchFull + } else { + fetchFull = func(cb func(io.Reader)) { + store.FetchFull([]models.UID{msg.Uid}, func(fm *types.FullMessage) { + if fm == nil || (fm != nil && fm.Content == nil) { + return + } + cb(fm.Content.Reader) + }) + } + } + + fetchFull(func(r io.Reader) { + tmpFile, err := os.Create(tmpFileName) + if err != nil { + log.Warnf("failed to create temporary attachment: %v", err) + _, err = addTab() + if err != nil { + log.Warnf("failed to add tab: %v", err) + } + return + } + + defer tmpFile.Close() + _, err = io.Copy(tmpFile, r) + if err != nil { + log.Warnf("failed to write to tmpfile: %v", err) + return + } + composer, err := addTab() + if err != nil { + return + } + composer.AddAttachment(tmpFileName) + composer.OnClose(func(c *app.Composer) { + if c.Sent() && store != nil { + store.Forwarded([]models.UID{msg.Uid}, true, nil) + } + os.RemoveAll(tmpDir) + }) + }) + } else { + if f.Template == "" { + f.Template = config.Templates.Forwards + } + + var fetchBodyPart func([]int, func(io.Reader)) + + if isMsgViewer { + fetchBodyPart = mv.MessageView().FetchBodyPart + } else { + fetchBodyPart = func(part []int, cb func(io.Reader)) { + store.FetchBodyPart(msg.Uid, part, cb) + } + } + + if crypto.IsEncrypted(msg.BodyStructure) && !isMsgViewer { + return fmt.Errorf("message is encrypted. " + + "can only forward from the message viewer") + } + + part := getMessagePart(msg, widget) + if part == nil { + part = lib.FindFirstNonMultipart(msg.BodyStructure, nil) + // if it's still nil here, we don't have a multipart msg, that's fine + } + + err = addMimeType(msg, part, &original) + if err != nil { + return err + } + + fetchBodyPart(part, func(reader io.Reader) { + buf := new(bytes.Buffer) + scanner := bufio.NewScanner(reader) + for scanner.Scan() { + buf.WriteString(scanner.Text() + "\n") + } + original.Text = buf.String() + + // create composer + composer, err := addTab() + if err != nil { + return + } + + composer.OnClose(func(c *app.Composer) { + if c.Sent() && store != nil { + store.Forwarded([]models.UID{msg.Uid}, true, nil) + } + }) + + // add attachments + if f.AttachAll { + var mu sync.Mutex + parts := lib.FindAllNonMultipart(msg.BodyStructure, nil, nil) + for _, p := range parts { + if lib.EqualParts(p, part) { + continue + } + bs, err := msg.BodyStructure.PartAtIndex(p) + if err != nil { + log.Errorf("cannot get PartAtIndex %v: %v", p, err) + continue + } + fetchBodyPart(p, func(reader io.Reader) { + mime := bs.FullMIMEType() + params := lib.SetUtf8Charset(bs.Params) + name := bs.FileName() + if name == "" { + name = fmt.Sprintf("%s_%s_%d", bs.MIMEType, bs.MIMESubType, rand.Uint64()) + } + mu.Lock() + err := composer.AddPartAttachment(name, mime, params, reader) + mu.Unlock() + if err != nil { + log.Errorf(err.Error()) + app.PushError(err.Error()) + } + }) + } + } + }) + } + return nil +} diff --git a/commands/msg/invite.go b/commands/msg/invite.go new file mode 100644 index 0000000..a779421 --- /dev/null +++ b/commands/msg/invite.go @@ -0,0 +1,180 @@ +package msg + +import ( + "errors" + "fmt" + "io" + "time" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/lib" + "git.sr.ht/~rjarry/aerc/lib/calendar" + "git.sr.ht/~rjarry/aerc/lib/format" + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/models" + "github.com/emersion/go-message/mail" +) + +type invite struct { + Edit bool `opt:"-e" desc:"Force [compose].edit-headers = true."` + NoEdit bool `opt:"-E" desc:"Force [compose].edit-headers = false."` + SkipEditor bool `opt:"-s" desc:"Skip the editor and go directly to the review screen."` +} + +func init() { + commands.Register(invite{}) +} + +func (invite) Description() string { + return "Accept or decline a meeting invitation." +} + +func (invite) Context() commands.CommandContext { + return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER +} + +func (invite) Aliases() []string { + return []string{"accept", "accept-tentative", "decline"} +} + +func (i invite) Execute(args []string) error { + acct := app.SelectedAccount() + if acct == nil { + return errors.New("no account selected") + } + store := acct.Store() + if store == nil { + return errors.New("cannot perform action: messages still loading") + } + msg, err := acct.SelectedMessage() + if err != nil { + return err + } + + part := lib.FindCalendartext(msg.BodyStructure, nil) + if part == nil { + return fmt.Errorf("no invitation found (missing text/calendar)") + } + + editHeaders := (config.Compose.EditHeaders || i.Edit) && !i.NoEdit + + subject := trimLocalizedRe(msg.Envelope.Subject, acct.AccountConfig().LocalizedRe) + switch args[0] { + case "accept": + subject = "Accepted: " + subject + case "accept-tentative": + subject = "Tentatively Accepted: " + subject + case "decline": + subject = "Declined: " + subject + default: + return fmt.Errorf("no participation status defined") + } + + from := chooseFromAddr(acct.AccountConfig(), msg) + + var to []*mail.Address + + if len(msg.Envelope.ReplyTo) != 0 { + to = msg.Envelope.ReplyTo + } else { + to = msg.Envelope.From + } + + if !config.Compose.ReplyToSelf { + for i, v := range to { + if v.Address == from.Address { + to = append(to[:i], to[i+1:]...) + break + } + } + if len(to) == 0 { + to = msg.Envelope.To + } + } + + recSet := newAddrSet() // used for de-duping + recSet.AddList(to) + + h := &mail.Header{} + h.SetAddressList("from", []*mail.Address{from}) + h.SetSubject(subject) + h.SetMsgIDList("in-reply-to", []string{msg.Envelope.MessageId}) + err = setReferencesHeader(h, msg.RFC822Headers) + if err != nil { + app.PushError(fmt.Sprintf("could not set references: %v", err)) + } + original := models.OriginalMail{ + From: format.FormatAddresses(msg.Envelope.From), + Date: msg.Envelope.Date, + RFC822Headers: msg.RFC822Headers, + } + + handleInvite := func(reader io.Reader) (*calendar.Reply, error) { + cr, err := calendar.CreateReply(reader, from, args[0]) + if err != nil { + return nil, err + } + for _, org := range cr.Organizers { + organizer, err := mail.ParseAddress(org) + if err != nil { + continue + } + if !recSet.Contains(organizer) { + to = append(to, organizer) + } + } + h.SetAddressList("to", to) + return cr, nil + } + + addTab := func(cr *calendar.Reply) error { + composer, err := app.NewComposer(acct, + acct.AccountConfig(), acct.Worker(), editHeaders, + "", h, &original, cr.PlainText) + if err != nil { + app.PushError("Error: " + err.Error()) + return err + } + err = composer.AppendPart(cr.MimeType, cr.Params, cr.CalendarText) + if err != nil { + return fmt.Errorf("failed to write invitation: %w", err) + } + if i.SkipEditor { + composer.Terminal().Close() + } else { + composer.FocusTerminal() + } + + composer.Tab = app.NewTab(composer, subject) + + composer.OnClose(func(c *app.Composer) { + switch { + case c.Sent() && c.Archive() != "": + store.Answered([]models.UID{msg.Uid}, true, nil) + err := archive([]*models.MessageInfo{msg}, nil, c.Archive()) + if err != nil { + app.PushStatus("Archive failed", 10*time.Second) + } + case c.Sent(): + store.Answered([]models.UID{msg.Uid}, true, nil) + } + }) + + return nil + } + + store.FetchBodyPart(msg.Uid, part, func(reader io.Reader) { + if cr, err := handleInvite(reader); err != nil { + app.PushError(err.Error()) + return + } else { + err := addTab(cr) + if err != nil { + log.Warnf("failed to add tab: %v", err) + } + } + }) + return nil +} diff --git a/commands/msg/mark.go b/commands/msg/mark.go new file mode 100644 index 0000000..21ba2fc --- /dev/null +++ b/commands/msg/mark.go @@ -0,0 +1,131 @@ +package msg + +import ( + "fmt" + + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/models" +) + +type Mark struct { + All bool `opt:"-a" aliases:"mark,unmark" desc:"Mark all messages in current folder."` + Toggle bool `opt:"-t" aliases:"mark,unmark" desc:"Toggle the marked state."` + Visual bool `opt:"-v" aliases:"mark,unmark" desc:"Enter / leave visual mark mode."` + VisualClear bool `opt:"-V" aliases:"mark,unmark" desc:"Same as -v but does not clear existing selection."` + Thread bool `opt:"-T" aliases:"mark,unmark" desc:"Mark all messages from the selected thread."` +} + +func init() { + commands.Register(Mark{}) +} + +func (Mark) Description() string { + return "Mark, unmark or remark messages." +} + +func (Mark) Context() commands.CommandContext { + return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER +} + +func (Mark) Aliases() []string { + return []string{"mark", "unmark", "remark"} +} + +func (m Mark) Execute(args []string) error { + h := newHelper() + OnSelectedMessage := func(fn func(models.UID)) error { + if fn == nil { + return fmt.Errorf("no operation selected") + } + selected, err := h.msgProvider.SelectedMessage() + if err != nil { + return err + } + fn(selected.Uid) + return nil + } + store, err := h.store() + if err != nil { + return err + } + marker := store.Marker() + + if m.Thread && m.All { + return fmt.Errorf("-a and -T are mutually exclusive") + } + + if m.Thread && (m.Visual || m.VisualClear) { + return fmt.Errorf("-v and -T are mutually exclusive") + } + if m.Visual && m.All { + return fmt.Errorf("-a and -v are mutually exclusive") + } + + switch args[0] { + case "mark": + var modFunc func(models.UID) + if m.Toggle { + modFunc = marker.ToggleMark + } else { + modFunc = marker.Mark + } + switch { + case m.All: + uids := store.Uids() + for _, uid := range uids { + modFunc(uid) + } + return nil + case m.Visual || m.VisualClear: + marker.ToggleVisualMark(m.VisualClear) + return nil + default: + if m.Thread { + threadPtr, err := store.SelectedThread() + if err != nil { + return err + } + for _, uid := range threadPtr.Root().Uids() { + modFunc(uid) + } + } else { + return OnSelectedMessage(modFunc) + } + return nil + } + + case "unmark": + if m.Visual || m.VisualClear { + return fmt.Errorf("visual mode not supported for this command") + } + + switch { + case m.All && m.Toggle: + uids := store.Uids() + for _, uid := range uids { + marker.ToggleMark(uid) + } + return nil + case m.All && !m.Toggle: + marker.ClearVisualMark() + return nil + default: + if m.Thread { + threadPtr, err := store.SelectedThread() + if err != nil { + return err + } + for _, uid := range threadPtr.Root().Uids() { + marker.Unmark(uid) + } + } else { + return OnSelectedMessage(marker.Unmark) + } + return nil + } + case "remark": + marker.Remark() + return nil + } + return nil // never reached +} diff --git a/commands/msg/modify-labels.go b/commands/msg/modify-labels.go new file mode 100644 index 0000000..f89d84a --- /dev/null +++ b/commands/msg/modify-labels.go @@ -0,0 +1,70 @@ +package msg + +import ( + "time" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/worker/types" +) + +type ModifyLabels struct { + Labels []string `opt:"..." metavar:"[+-]<label>" complete:"CompleteLabels" desc:"Message label."` +} + +func init() { + commands.Register(ModifyLabels{}) +} + +func (ModifyLabels) Description() string { + return "Modify message labels." +} + +func (ModifyLabels) Context() commands.CommandContext { + return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER +} + +func (ModifyLabels) Aliases() []string { + return []string{"modify-labels", "tag"} +} + +func (*ModifyLabels) CompleteLabels(arg string) []string { + return commands.GetLabels(arg) +} + +func (m ModifyLabels) Execute(args []string) error { + h := newHelper() + store, err := h.store() + if err != nil { + return err + } + uids, err := h.markedOrSelectedUids() + if err != nil { + return err + } + + var add, remove []string + for _, l := range m.Labels { + switch l[0] { + case '+': + add = append(add, l[1:]) + case '-': + remove = append(remove, l[1:]) + default: + // if no operand is given assume add + add = append(add, l) + } + } + store.ModifyLabels(uids, add, remove, func( + msg types.WorkerMessage, + ) { + switch msg := msg.(type) { + case *types.Done: + app.PushStatus("labels updated", 10*time.Second) + store.Marker().ClearVisualMark() + case *types.Error: + app.PushError(msg.Error.Error()) + } + }) + return nil +} diff --git a/commands/msg/move.go b/commands/msg/move.go new file mode 100644 index 0000000..80f13a2 --- /dev/null +++ b/commands/msg/move.go @@ -0,0 +1,267 @@ +package msg + +import ( + "bytes" + "fmt" + "time" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/lib" + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/marker" + "git.sr.ht/~rjarry/aerc/lib/ui" + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/types" +) + +type Move struct { + CreateFolders bool `opt:"-p" desc:"Create missing folders if required."` + Account string `opt:"-a" complete:"CompleteAccount" desc:"Move to specified account."` + MultiFileStrategy *types.MultiFileStrategy `opt:"-m" action:"ParseMFS" complete:"CompleteMFS" desc:"Multi-file strategy."` + Folder string `opt:"folder" complete:"CompleteFolder" desc:"Target folder."` +} + +func init() { + commands.Register(Move{}) +} + +func (Move) Description() string { + return "Move the selected message(s) to the specified folder." +} + +func (Move) Context() commands.CommandContext { + return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER +} + +func (Move) Aliases() []string { + return []string{"mv", "move"} +} + +func (m *Move) ParseMFS(arg string) error { + if arg != "" { + mfs, ok := types.StrToStrategy[arg] + if !ok { + return fmt.Errorf("invalid multi-file strategy %s", arg) + } + m.MultiFileStrategy = &mfs + } + return nil +} + +func (*Move) CompleteAccount(arg string) []string { + return commands.FilterList(app.AccountNames(), arg, commands.QuoteSpace) +} + +func (m *Move) CompleteFolder(arg string) []string { + var acct *app.AccountView + if len(m.Account) > 0 { + acct, _ = app.Account(m.Account) + } else { + acct = app.SelectedAccount() + } + if acct == nil { + return nil + } + return commands.FilterList(acct.Directories().List(), arg, nil) +} + +func (Move) CompleteMFS(arg string) []string { + return commands.FilterList(types.StrategyStrs(), arg, nil) +} + +func (m Move) Execute(args []string) error { + h := newHelper() + acct, err := h.account() + if err != nil { + return err + } + store, err := h.store() + if err != nil { + return err + } + uids, err := h.markedOrSelectedUids() + if err != nil { + return err + } + + next := findNextNonDeleted(uids, store) + marker := store.Marker() + marker.ClearVisualMark() + + if len(m.Account) == 0 { + store.Move(uids, m.Folder, m.CreateFolders, m.MultiFileStrategy, + func(msg types.WorkerMessage) { + m.CallBack(msg, acct, uids, next, marker, false) + }) + return nil + } + + destAcct, err := app.Account(m.Account) + if err != nil { + return err + } + + destStore := destAcct.Store() + if destStore == nil { + app.PushError(fmt.Sprintf("No message store in %s", m.Account)) + return nil + } + + var messages []*types.FullMessage + fetchDone := make(chan bool, 1) + store.FetchFull(uids, func(fm *types.FullMessage) { + messages = append(messages, fm) + if len(messages) == len(uids) { + fetchDone <- true + } + }) + + // Since this operation can take some time with some backends + // (e.g. IMAP), provide some feedback to inform the user that + // something is happening + app.PushStatus("Moving messages...", 10*time.Second) + + var appended []models.UID + var timeout bool + go func() { + defer log.PanicHandler() + + select { + case <-fetchDone: + break + case <-time.After(30 * time.Second): + // TODO: find a better way to determine if store.FetchFull() + // has finished with some errors. + app.PushError("Failed to fetch all messages") + if len(messages) == 0 { + return + } + } + + AppendLoop: + for _, fm := range messages { + done := make(chan bool, 1) + uid := fm.Content.Uid + buf := new(bytes.Buffer) + _, err = buf.ReadFrom(fm.Content.Reader) + if err != nil { + log.Errorf("could not get reader for uid %d", uid) + break + } + destStore.Append( + m.Folder, + models.SeenFlag, + time.Now(), + buf, + buf.Len(), + func(msg types.WorkerMessage) { + switch msg := msg.(type) { + case *types.Done: + appended = append(appended, uid) + done <- true + case *types.Error: + log.Errorf("AppendMessage failed: %v", msg.Error) + done <- false + } + }, + ) + select { + case ok := <-done: + if !ok { + break AppendLoop + } + case <-time.After(30 * time.Second): + log.Warnf("timed-out: appended %d of %d", len(appended), len(messages)) + timeout = true + break AppendLoop + } + } + if len(appended) > 0 { + mfs := types.Refuse + store.Delete(appended, &mfs, func(msg types.WorkerMessage) { + m.CallBack(msg, acct, appended, next, marker, timeout) + }) + } + }() + return nil +} + +func (m Move) CallBack( + msg types.WorkerMessage, + acct *app.AccountView, + uids []models.UID, + next *models.MessageInfo, + marker marker.Marker, + timeout bool, +) { + switch msg := msg.(type) { + case *types.Done: + var s string + if len(uids) > 1 { + s = "%d messages moved to %s" + } else { + s = "%d message moved to %s" + } + dest := m.Folder + if len(m.Account) > 0 { + dest = fmt.Sprintf("%s in %s", m.Folder, m.Account) + } + if timeout { + s = "timed-out: only " + s + app.PushError(fmt.Sprintf(s, len(uids), dest)) + } else { + app.PushStatus(fmt.Sprintf(s, len(uids), dest), 10*time.Second) + } + if store := acct.Store(); store != nil { + handleDone(acct, next, store) + } + case *types.Error: + app.PushError(msg.Error.Error()) + marker.Remark() + case *types.Unsupported: + marker.Remark() + app.PushError("error, unsupported for this worker") + } +} + +func handleDone( + acct *app.AccountView, + next *models.MessageInfo, + store *lib.MessageStore, +) { + h := newHelper() + mv, isMsgView := h.msgProvider.(*app.MessageViewer) + switch { + case isMsgView && !config.Ui.NextMessageOnDelete: + app.RemoveTab(h.msgProvider, true) + case isMsgView: + if next == nil { + app.RemoveTab(h.msgProvider, true) + acct.Messages().Select(-1) + ui.Invalidate() + return + } + lib.NewMessageStoreView(next, mv.MessageView().SeenFlagSet(), + store, app.CryptoProvider(), app.DecryptKeys, + func(view lib.MessageView, err error) { + if err != nil { + app.PushError(err.Error()) + return + } + nextMv, err := app.NewMessageViewer(acct, view) + if err != nil { + app.PushError(err.Error()) + return + } + app.ReplaceTab(mv, nextMv, next.Envelope.Subject, true) + }) + default: + if next == nil { + // We moved the last message, select the new last message + // instead of the first message + acct.Messages().Select(-1) + } + } +} diff --git a/commands/msg/pipe.go b/commands/msg/pipe.go new file mode 100644 index 0000000..c160247 --- /dev/null +++ b/commands/msg/pipe.go @@ -0,0 +1,302 @@ +package msg + +import ( + "bytes" + "errors" + "fmt" + "io" + "os/exec" + "regexp" + "sort" + "strings" + "time" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" + cryptoutil "git.sr.ht/~rjarry/aerc/lib/crypto/util" + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/models" + mboxer "git.sr.ht/~rjarry/aerc/worker/mbox" + "git.sr.ht/~rjarry/aerc/worker/types" +) + +type Pipe struct { + Background bool `opt:"-b" desc:"Run the command in the background."` + Silent bool `opt:"-s" desc:"Silently close the terminal tab after the command exits."` + Full bool `opt:"-m" desc:"Pipe the full message."` + Decrypt bool `opt:"-d" desc:"Decrypt the full message before piping."` + Part bool `opt:"-p" desc:"Only pipe the selected message part."` + Command string `opt:"..."` +} + +func init() { + commands.Register(Pipe{}) +} + +func (Pipe) Description() string { + return "Pipe the selected message(s) into the given shell command." +} + +func (Pipe) Context() commands.CommandContext { + return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER +} + +func (Pipe) Aliases() []string { + return []string{"pipe"} +} + +func (p Pipe) Execute(args []string) error { + return p.Run(nil) +} + +func (p Pipe) Run(cb func()) error { + if p.Decrypt { + // Decrypt implies fetching the full message + p.Full = true + } + if p.Full && p.Part { + return errors.New("-m and -p are mutually exclusive") + } + name, _, _ := strings.Cut(p.Command, " ") + + provider := app.SelectedTabContent().(app.ProvidesMessage) + if !p.Full && !p.Part { + if _, ok := provider.(*app.MessageViewer); ok { + p.Part = true + } else if _, ok := provider.(*app.AccountView); ok { + p.Full = true + } else { + return errors.New( + "Neither -m nor -p specified and cannot infer default") + } + } + + doTerm := func(reader io.Reader, name string) { + cmd := []string{"sh", "-c", p.Command} + term, err := commands.QuickTerm(cmd, reader, p.Silent) + if err != nil { + app.PushError(err.Error()) + return + } + if cb != nil { + last := term.OnClose + term.OnClose = func(err error) { + if last != nil { + last(err) + } + cb() + } + } + app.NewTab(term, name) + } + + doExec := func(reader io.Reader) { + ecmd := exec.Command("sh", "-c", p.Command) + pipe, err := ecmd.StdinPipe() + if err != nil { + return + } + go func() { + defer log.PanicHandler() + + defer pipe.Close() + _, err := io.Copy(pipe, reader) + if err != nil { + log.Errorf("failed to send data to pipe: %v", err) + } + }() + err = ecmd.Run() + if err != nil { + app.PushError(err.Error()) + } else { + if ecmd.ProcessState.ExitCode() != 0 { + app.PushError(fmt.Sprintf( + "%s: completed with status %d", name, + ecmd.ProcessState.ExitCode())) + } else { + app.PushStatus(fmt.Sprintf( + "%s: completed with status %d", name, + ecmd.ProcessState.ExitCode()), 10*time.Second) + } + } + if cb != nil { + cb() + } + } + + app.PushStatus("Fetching messages ...", 10*time.Second) + + if p.Full { + var uids []models.UID + var title string + + h := newHelper() + store, err := h.store() + if err != nil { + if mv, ok := provider.(*app.MessageViewer); ok { + mv.MessageView().FetchFull(func(reader io.Reader) { + if p.Background { + doExec(reader) + } else { + doTerm(reader, + fmt.Sprintf("%s <%s", + name, title)) + } + }) + return nil + } + return err + } + uids, err = h.markedOrSelectedUids() + if err != nil { + return err + } + + if len(uids) == 1 { + info := store.Messages[uids[0]] + if info != nil { + envelope := info.Envelope + if envelope != nil { + title = envelope.Subject + } + } + } + if title == "" { + title = fmt.Sprintf("%d messages", len(uids)) + } + + var messages []*types.FullMessage + var errors []error + done := make(chan bool, 1) + + store.FetchFull(uids, func(fm *types.FullMessage) { + if p.Decrypt { + info := store.Messages[fm.Content.Uid] + if info == nil { + goto addMessage + } + var buf bytes.Buffer + cleartext, err := cryptoutil.Cleartext( + io.TeeReader(fm.Content.Reader, &buf), + info.RFC822Headers.Copy(), + ) + if err != nil { + log.Warnf("continue encrypted: %v", err) + fm.Content.Reader = bytes.NewReader(buf.Bytes()) + } else { + fm.Content.Reader = bytes.NewReader(cleartext) + } + } + addMessage: + info := store.Messages[fm.Content.Uid] + switch { + case info != nil && info.Envelope != nil: + messages = append(messages, fm) + case info != nil && info.Error != nil: + app.PushError(info.Error.Error()) + errors = append(errors, info.Error) + default: + err := fmt.Errorf("%v nil info", fm.Content.Uid) + app.PushError(err.Error()) + errors = append(errors, err) + } + if len(messages)+len(errors) == len(uids) { + done <- true + } + }) + + go func() { + defer log.PanicHandler() + + select { + case <-done: + break + case <-time.After(30 * time.Second): + // TODO: find a better way to determine if store.FetchFull() + // has finished with some errors. + app.PushError("Failed to fetch all messages") + if len(messages) == 0 { + return + } + } + + is_git_patches := false + for _, msg := range messages { + info := store.Messages[msg.Content.Uid] + if info == nil || info.Envelope == nil { + continue + } + if patchSeriesRe.MatchString(info.Envelope.Subject) { + is_git_patches = true + break + } + } + if is_git_patches { + // Sort all messages by increasing Message-Id header. + // This will ensure that patch series are applied in order. + sort.Slice(messages, func(i, j int) bool { + infoi := store.Messages[messages[i].Content.Uid] + infoj := store.Messages[messages[j].Content.Uid] + if infoi == nil || infoi.Envelope == nil || + infoj == nil || infoj.Envelope == nil { + return false + } + return infoi.Envelope.Subject < infoj.Envelope.Subject + }) + } + + reader := newMessagesReader(messages, len(messages) > 1) + if p.Background { + doExec(reader) + } else { + doTerm(reader, fmt.Sprintf("%s <%s", name, title)) + } + }() + } else if p.Part { + mv, ok := provider.(*app.MessageViewer) + if !ok { + return fmt.Errorf("can only pipe message part from a message view") + } + part := provider.SelectedMessagePart() + if part == nil { + return fmt.Errorf("could not fetch message part") + } + mv.MessageView().FetchBodyPart(part.Index, func(reader io.Reader) { + if p.Background { + doExec(reader) + } else { + name := fmt.Sprintf("%s <%s/[%d]", + name, part.Msg.Envelope.Subject, part.Index) + doTerm(reader, name) + } + }) + } + if store := provider.Store(); store != nil { + store.Marker().ClearVisualMark() + } + return nil +} + +func newMessagesReader(messages []*types.FullMessage, useMbox bool) io.Reader { + pr, pw := io.Pipe() + go func() { + defer log.PanicHandler() + defer pw.Close() + for _, msg := range messages { + var err error + if useMbox { + err = mboxer.Write(pw, msg.Content.Reader, "", time.Now()) + } else { + _, err = io.Copy(pw, msg.Content.Reader) + } + if err != nil { + log.Warnf("failed to write data: %v", err) + } + } + }() + return pr +} + +var patchSeriesRe = regexp.MustCompile( + `^.*\[(RFC )?PATCH( [^\]]+)? \d+/\d+] .+$`, +) diff --git a/commands/msg/read.go b/commands/msg/read.go new file mode 100644 index 0000000..3c048e6 --- /dev/null +++ b/commands/msg/read.go @@ -0,0 +1,162 @@ +package msg + +import ( + "fmt" + "strings" + "time" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/types" +) + +type FlagMsg struct { + Toggle bool `opt:"-t" desc:"Toggle between set and unset."` + Answered bool `opt:"-a" aliases:"flag,unflag" desc:"Set/unset the answered flag."` + Forwarded bool `opt:"-f" aliases:"flag,unflag" desc:"Set/unset the forwarded flag."` + Flag models.Flags `opt:"-x" aliases:"flag,unflag" action:"ParseFlag" complete:"CompleteFlag" desc:"Flag name."` + FlagName string +} + +func init() { + commands.Register(FlagMsg{}) +} + +func (FlagMsg) Description() string { + return "Set or unset a flag on the marked or selected messages." +} + +func (FlagMsg) Context() commands.CommandContext { + return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER +} + +func (FlagMsg) Aliases() []string { + return []string{"flag", "unflag", "read", "unread"} +} + +func (f *FlagMsg) ParseFlag(arg string) error { + switch strings.ToLower(arg) { + case "seen": + f.Flag = models.SeenFlag + f.FlagName = "seen" + case "answered": + f.Flag = models.AnsweredFlag + f.FlagName = "answered" + case "forwarded": + f.Flag = models.ForwardedFlag + f.FlagName = "forwarded" + case "flagged": + f.Flag = models.FlaggedFlag + f.FlagName = "flagged" + case "draft": + f.Flag = models.DraftFlag + f.FlagName = "draft" + default: + return fmt.Errorf("Unknown flag %q", arg) + } + return nil +} + +var validFlags = []string{"seen", "answered", "forwarded", "flagged", "draft"} + +func (*FlagMsg) CompleteFlag(arg string) []string { + return commands.FilterList(validFlags, arg, nil) +} + +// If this was called as 'flag' or 'unflag', without the toggle (-t) +// option, then it will flag the corresponding messages with the given +// flag. If the toggle option was given, it will individually toggle +// the given flag for the corresponding messages. +// +// If this was called as 'read' or 'unread', it has the same effect as +// 'flag' or 'unflag', respectively, but the 'Seen' flag is affected. +func (f FlagMsg) Execute(args []string) error { + // User-readable name for the action being performed + var actionName string + + switch args[0] { + case "read", "unread": + f.Flag = models.SeenFlag + f.FlagName = "seen" + case "flag", "unflag": + if f.Answered { + f.Flag = models.AnsweredFlag + f.FlagName = "answered" + } + if f.Forwarded { + f.Flag = models.ForwardedFlag + f.FlagName = "forwarded" + } + if f.Flag == 0 { + f.Flag = models.FlaggedFlag + f.FlagName = "flagged" + } + } + + h := newHelper() + store, err := h.store() + if err != nil { + return err + } + + // UIDs of messages to enable or disable the flag for. + var toEnable []models.UID + var toDisable []models.UID + + if f.Toggle { + // If toggling, split messages into those that need to + // be enabled / disabled. + msgs, err := h.messages() + if err != nil { + return err + } + for _, m := range msgs { + if m.Flags.Has(f.Flag) { + toDisable = append(toDisable, m.Uid) + } else { + toEnable = append(toEnable, m.Uid) + } + } + actionName = "Toggling" + } else { + msgUids, err := h.markedOrSelectedUids() + if err != nil { + return err + } + switch args[0] { + case "read", "flag": + toEnable = msgUids + actionName = "Setting" + default: + toDisable = msgUids + actionName = "Unsetting" + } + } + + status := fmt.Sprintf("%s flag %q successful", actionName, f.FlagName) + + if len(toEnable) != 0 { + store.Flag(toEnable, f.Flag, true, func(msg types.WorkerMessage) { + switch msg := msg.(type) { + case *types.Done: + app.PushStatus(status, 10*time.Second) + store.Marker().ClearVisualMark() + case *types.Error: + app.PushError(msg.Error.Error()) + } + }) + } + if len(toDisable) != 0 { + store.Flag(toDisable, f.Flag, false, func(msg types.WorkerMessage) { + switch msg := msg.(type) { + case *types.Done: + app.PushStatus(status, 10*time.Second) + store.Marker().ClearVisualMark() + case *types.Error: + app.PushError(msg.Error.Error()) + } + }) + } + return nil +} diff --git a/commands/msg/recall.go b/commands/msg/recall.go new file mode 100644 index 0000000..7bdc0cb --- /dev/null +++ b/commands/msg/recall.go @@ -0,0 +1,177 @@ +package msg + +import ( + "fmt" + "io" + "math/rand" + "sync" + "time" + + _ "github.com/emersion/go-message/charset" + "github.com/pkg/errors" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/lib" + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/types" +) + +type Recall struct { + Force bool `opt:"-f" desc:"Force recall if not in postpone directory."` + Edit bool `opt:"-e" desc:"Force [compose].edit-headers = true."` + NoEdit bool `opt:"-E" desc:"Force [compose].edit-headers = false."` + SkipEditor bool `opt:"-s" desc:"Skip the editor and go directly to the review screen."` +} + +func init() { + commands.Register(Recall{}) +} + +func (Recall) Description() string { + return "Open a postponed message for re-editing." +} + +func (Recall) Context() commands.CommandContext { + return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER +} + +func (Recall) Aliases() []string { + return []string{"recall"} +} + +func (r Recall) Execute(args []string) error { + editHeaders := (config.Compose.EditHeaders || r.Edit) && !r.NoEdit + + widget := app.SelectedTabContent().(app.ProvidesMessage) + acct := widget.SelectedAccount() + if acct == nil { + return errors.New("No account selected") + } + store := widget.Store() + if store == nil { + return errors.New("Cannot perform action. Messages still loading") + } + + msgInfo, err := widget.SelectedMessage() + if err != nil { + return errors.Wrap(err, "Recall failed") + } + + if acct.SelectedDirectory() != acct.AccountConfig().Postpone && + !msgInfo.Flags.Has(models.DraftFlag) && !r.Force { + return errors.New("Use -f to recall non-draft messages from outside the " + + acct.AccountConfig().Postpone + " directory.") + } + + log.Debugf("Recalling message <%s>", msgInfo.Envelope.MessageId) + + addTab := func(composer *app.Composer) { + subject := msgInfo.Envelope.Subject + if subject == "" { + subject = "Recalled email" + } + composer.Tab = app.NewTab(composer, subject) + composer.OnClose(func(composer *app.Composer) { + uids := []models.UID{msgInfo.Uid} + + deleteMessage := func() { + store.Delete( + uids, + nil, + func(msg types.WorkerMessage) { + switch msg := msg.(type) { + case *types.Done: + app.PushStatus("Recalled message deleted", 10*time.Second) + case *types.Error: + app.PushError(msg.Error.Error()) + } + }, + ) + } + + if composer.Sent() || composer.Postponed() { + deleteMessage() + } + }) + } + + lib.NewMessageStoreView(msgInfo, acct.UiConfig().AutoMarkRead, + store, app.CryptoProvider(), app.DecryptKeys, + func(msg lib.MessageView, err error) { + if err != nil { + app.PushError(err.Error()) + return + } + var path []int + if len(msg.BodyStructure().Parts) != 0 { + path = lib.FindPlaintext(msg.BodyStructure(), path) + } + + msg.FetchBodyPart(path, func(reader io.Reader) { + composer, err := app.NewComposer(acct, + acct.AccountConfig(), acct.Worker(), editHeaders, + "", msgInfo.RFC822Headers, nil, reader) + if err != nil { + app.PushError(err.Error()) + return + } + if md := msg.MessageDetails(); md != nil { + if md.IsEncrypted { + composer.SetEncrypt(md.IsEncrypted) + } + if md.IsSigned { + err = composer.SetSign(md.IsSigned) + if err != nil { + log.Warnf("failed to set signed state: %v", err) + } + } + } + + // add attachments if present + var mu sync.Mutex + parts := lib.FindAllNonMultipart(msg.BodyStructure(), nil, nil) + for _, p := range parts { + if lib.EqualParts(p, path) { + continue + } + bs, err := msg.BodyStructure().PartAtIndex(p) + if err != nil { + log.Warnf("cannot get PartAtIndex %v: %v", p, err) + continue + } + msg.FetchBodyPart(p, func(reader io.Reader) { + mime := bs.FullMIMEType() + params := lib.SetUtf8Charset(bs.Params) + name, ok := params["name"] + if !ok { + name = fmt.Sprintf("%s_%s_%d", bs.MIMEType, bs.MIMESubType, rand.Uint64()) + } + mu.Lock() + err := composer.AddPartAttachment(name, mime, params, reader) + mu.Unlock() + if err != nil { + log.Errorf(err.Error()) + app.PushError(err.Error()) + } + }) + } + + if r.Force { + composer.SetRecalledFrom(acct.SelectedDirectory()) + } + + if r.SkipEditor { + composer.Terminal().Close() + } else { + // focus the terminal since the header fields are likely already done + composer.FocusTerminal() + } + addTab(composer) + }) + }) + + return nil +} diff --git a/commands/msg/reply.go b/commands/msg/reply.go new file mode 100644 index 0000000..db72cf0 --- /dev/null +++ b/commands/msg/reply.go @@ -0,0 +1,354 @@ +package msg + +import ( + "errors" + "fmt" + "io" + "regexp" + "strings" + "time" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/commands/account" + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/lib" + "git.sr.ht/~rjarry/aerc/lib/crypto" + "git.sr.ht/~rjarry/aerc/lib/format" + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/parse" + "git.sr.ht/~rjarry/aerc/models" + "github.com/danwakefield/fnmatch" + "github.com/emersion/go-message/mail" +) + +type reply struct { + All bool `opt:"-a" desc:"Reply to all recipients."` + Close bool `opt:"-c" desc:"Close the view tab when replying."` + From bool `opt:"-f" desc:"Reply to all addresses in From and Reply-To headers."` + Quote bool `opt:"-q" desc:"Alias of -T quoted-reply."` + Template string `opt:"-T" complete:"CompleteTemplate" desc:"Template name."` + Edit bool `opt:"-e" desc:"Force [compose].edit-headers = true."` + NoEdit bool `opt:"-E" desc:"Force [compose].edit-headers = false."` + Account string `opt:"-A" complete:"CompleteAccount" desc:"Reply with the specified account."` + SkipEditor bool `opt:"-s" desc:"Skip the editor and go directly to the review screen."` +} + +func init() { + commands.Register(reply{}) +} + +func (reply) Description() string { + return "Open the composer to reply to the selected message." +} + +func (reply) Context() commands.CommandContext { + return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER +} + +func (reply) Aliases() []string { + return []string{"reply"} +} + +func (*reply) CompleteTemplate(arg string) []string { + return commands.GetTemplates(arg) +} + +func (*reply) CompleteAccount(arg string) []string { + return commands.FilterList(app.AccountNames(), arg, commands.QuoteSpace) +} + +func (r reply) Execute(args []string) error { + editHeaders := (config.Compose.EditHeaders || r.Edit) && !r.NoEdit + + widget := app.SelectedTabContent().(app.ProvidesMessage) + + var acct *app.AccountView + var err error + + if r.Account == "" { + acct = widget.SelectedAccount() + if acct == nil { + return errors.New("No account selected") + } + } else { + acct, err = app.Account(r.Account) + if err != nil { + return err + } + } + conf := acct.AccountConfig() + + msg, err := widget.SelectedMessage() + if err != nil { + return err + } + + from := chooseFromAddr(conf, msg) + + var ( + to []*mail.Address + cc []*mail.Address + ) + + recSet := newAddrSet() // used for de-duping + dedupe := func(addrs []*mail.Address) []*mail.Address { + deduped := make([]*mail.Address, 0, len(addrs)) + for _, addr := range addrs { + if recSet.Contains(addr) { + continue + } + recSet.Add(addr) + deduped = append(deduped, addr) + } + return deduped + } + + if !config.Compose.ReplyToSelf { + recSet.Add(from) + } + + switch { + case len(msg.Envelope.ReplyTo) != 0: + to = dedupe(msg.Envelope.ReplyTo) + case len(msg.Envelope.From) != 0: + to = dedupe(msg.Envelope.From) + default: + to = dedupe(msg.Envelope.Sender) + } + + if r.From { + to = append(to, dedupe(msg.Envelope.From)...) + } + + if !config.Compose.ReplyToSelf && len(to) == 0 { + recSet = newAddrSet() + to = dedupe(msg.Envelope.To) + } + + if r.All { + // order matters, due to the deduping + // in order of importance, first parse the To, then the Cc header + + // we add our from address, so that we don't self address ourselves + recSet.Add(from) + + to = append(to, dedupe(msg.Envelope.To)...) + + cc = append(cc, dedupe(msg.Envelope.Cc)...) + cc = append(cc, dedupe(msg.Envelope.Sender)...) + } + + subject := "Re: " + trimLocalizedRe(msg.Envelope.Subject, conf.LocalizedRe) + + h := &mail.Header{} + h.SetAddressList("to", to) + h.SetAddressList("cc", cc) + h.SetAddressList("from", []*mail.Address{from}) + h.SetSubject(subject) + h.SetMsgIDList("in-reply-to", []string{msg.Envelope.MessageId}) + err = setReferencesHeader(h, msg.RFC822Headers) + if err != nil { + app.PushError(fmt.Sprintf("could not set references: %v", err)) + } + original := models.OriginalMail{ + From: format.FormatAddresses(msg.Envelope.From), + Date: msg.Envelope.Date, + RFC822Headers: msg.RFC822Headers, + } + + mv, isMsgViewer := app.SelectedTabContent().(*app.MessageViewer) + + store := widget.Store() + noStore := store == nil + switch { + case noStore && isMsgViewer: + app.PushWarning("No message store found: answered flag cannot be set") + case noStore: + return errors.New("Cannot perform action. Messages still loading") + default: + original.Folder = store.Name + } + + addTab := func() error { + composer, err := app.NewComposer(acct, + acct.AccountConfig(), acct.Worker(), editHeaders, + r.Template, h, &original, nil) + if err != nil { + app.PushError("Error: " + err.Error()) + return err + } + if mv != nil && r.Close { + app.RemoveTab(mv, true) + } + + if r.SkipEditor { + composer.Terminal().Close() + } else if args[0] == "reply" { + composer.FocusTerminal() + } + + composer.Tab = app.NewTab(composer, subject) + + composer.OnClose(func(c *app.Composer) { + switch { + case c.Sent() && c.Archive() != "" && !noStore: + store.Answered([]models.UID{msg.Uid}, true, nil) + err := archive([]*models.MessageInfo{msg}, nil, c.Archive()) + if err != nil { + app.PushStatus("Archive failed", 10*time.Second) + } + case c.Sent() && !noStore: + store.Answered([]models.UID{msg.Uid}, true, nil) + case mv != nil && r.Close: + view := account.ViewMessage{Peek: true} + //nolint:errcheck // who cares? + view.Execute([]string{"view", "-p"}) + } + }) + + return nil + } + + if r.Quote && r.Template == "" { + r.Template = config.Templates.QuotedReply + } + + if r.Template != "" { + var fetchBodyPart func([]int, func(io.Reader)) + + if isMsgViewer { + fetchBodyPart = mv.MessageView().FetchBodyPart + } else { + fetchBodyPart = func(part []int, cb func(io.Reader)) { + store.FetchBodyPart(msg.Uid, part, cb) + } + } + + if crypto.IsEncrypted(msg.BodyStructure) && !isMsgViewer { + return fmt.Errorf("message is encrypted. " + + "can only include reply from the message viewer") + } + + part := getMessagePart(msg, widget) + if part == nil { + // mkey... let's get the first thing that isn't a container + // if that's still nil it's either not a multipart msg (ok) or + // broken (containers only) + part = lib.FindFirstNonMultipart(msg.BodyStructure, nil) + } + + err = addMimeType(msg, part, &original) + if err != nil { + return err + } + + fetchBodyPart(part, func(reader io.Reader) { + data, err := io.ReadAll(reader) + if err != nil { + log.Warnf("failed to read bodypart: %v", err) + } + original.Text = string(data) + err = addTab() + if err != nil { + log.Warnf("failed to add tab: %v", err) + } + }) + + return nil + } else { + r.Template = config.Templates.NewMessage + return addTab() + } +} + +func chooseFromAddr(conf *config.AccountConfig, msg *models.MessageInfo) *mail.Address { + if len(conf.Aliases) == 0 { + return conf.From + } + + rec := newAddrSet() + rec.AddList(msg.Envelope.From) + rec.AddList(msg.Envelope.To) + rec.AddList(msg.Envelope.Cc) + // test the from first, it has priority over any present alias + if rec.Contains(conf.From) { + // do nothing + } else { + for _, a := range conf.Aliases { + if match := rec.FindMatch(a); match != "" { + return &mail.Address{Name: a.Name, Address: match} + } + } + } + + return conf.From +} + +type addrSet map[string]struct{} + +func newAddrSet() addrSet { + s := make(map[string]struct{}) + return addrSet(s) +} + +func (s addrSet) Add(a *mail.Address) { + s[a.Address] = struct{}{} +} + +func (s addrSet) AddList(al []*mail.Address) { + for _, a := range al { + s[a.Address] = struct{}{} + } +} + +func (s addrSet) Contains(a *mail.Address) bool { + _, ok := s[a.Address] + return ok +} + +func (s addrSet) FindMatch(a *mail.Address) string { + for addr := range s { + if fnmatch.Match(a.Address, addr, 0) { + return addr + } + } + + return "" +} + +// setReferencesHeader adds the references header to target based on parent +// according to RFC2822 +func setReferencesHeader(target, parent *mail.Header) error { + refs := parse.MsgIDList(parent, "references") + if len(refs) == 0 { + // according to the RFC we need to fall back to in-reply-to only if + // References is not set + refs = parse.MsgIDList(parent, "in-reply-to") + } + msgID, err := parent.MessageID() + if err != nil { + return err + } + refs = append(refs, msgID) + target.SetMsgIDList("references", refs) + return nil +} + +// addMimeType adds the proper mime type of the part to the originalMail struct +func addMimeType(msg *models.MessageInfo, part []int, + orig *models.OriginalMail, +) error { + // caution, :forward uses the code as well, keep that in mind when modifying + bs, err := msg.BodyStructure.PartAtIndex(part) + if err != nil { + return err + } + orig.MIMEType = bs.FullMIMEType() + return nil +} + +// trimLocalizedRe removes known localizations of Re: commonly used by Outlook. +func trimLocalizedRe(subject string, localizedRe *regexp.Regexp) string { + return strings.TrimPrefix(subject, localizedRe.FindString(subject)) +} diff --git a/commands/msg/toggle-thread-context.go b/commands/msg/toggle-thread-context.go new file mode 100644 index 0000000..fed87ba --- /dev/null +++ b/commands/msg/toggle-thread-context.go @@ -0,0 +1,35 @@ +package msg + +import ( + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/lib/ui" +) + +type ToggleThreadContext struct{} + +func init() { + commands.Register(ToggleThreadContext{}) +} + +func (ToggleThreadContext) Description() string { + return "Show/hide message thread context." +} + +func (ToggleThreadContext) Context() commands.CommandContext { + return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER +} + +func (ToggleThreadContext) Aliases() []string { + return []string{"toggle-thread-context"} +} + +func (ToggleThreadContext) Execute(args []string) error { + h := newHelper() + store, err := h.store() + if err != nil { + return err + } + store.ToggleThreadContext() + ui.Invalidate() + return nil +} diff --git a/commands/msg/toggle-threads.go b/commands/msg/toggle-threads.go new file mode 100644 index 0000000..b8ebc15 --- /dev/null +++ b/commands/msg/toggle-threads.go @@ -0,0 +1,41 @@ +package msg + +import ( + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/lib/state" + "git.sr.ht/~rjarry/aerc/lib/ui" +) + +type ToggleThreads struct{} + +func init() { + commands.Register(ToggleThreads{}) +} + +func (ToggleThreads) Description() string { + return "Toggle between message threading and the normal message list." +} + +func (ToggleThreads) Context() commands.CommandContext { + return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER +} + +func (ToggleThreads) Aliases() []string { + return []string{"toggle-threads"} +} + +func (ToggleThreads) Execute(args []string) error { + h := newHelper() + acct, err := h.account() + if err != nil { + return err + } + store, err := h.store() + if err != nil { + return err + } + store.SetThreadedView(!store.ThreadedView()) + acct.SetStatus(state.Threading(store.ThreadedView())) + ui.Invalidate() + return nil +} diff --git a/commands/msg/unsubscribe.go b/commands/msg/unsubscribe.go new file mode 100644 index 0000000..1d31632 --- /dev/null +++ b/commands/msg/unsubscribe.go @@ -0,0 +1,202 @@ +package msg + +import ( + "bufio" + "errors" + "fmt" + "net/url" + "strings" + "time" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/lib" + "git.sr.ht/~rjarry/aerc/lib/log" + "github.com/emersion/go-message/mail" +) + +// Unsubscribe helps people unsubscribe from mailing lists by way of the +// List-Unsubscribe header. +type Unsubscribe struct { + Edit bool `opt:"-e" desc:"Force [compose].edit-headers = true."` + NoEdit bool `opt:"-E" desc:"Force [compose].edit-headers = false."` + SkipEditor bool `opt:"-s" desc:"Skip the editor and go directly to the review screen."` +} + +func init() { + commands.Register(Unsubscribe{}) +} + +func (Unsubscribe) Description() string { + return "Attempt to automatically unsubscribe from mailing lists." +} + +func (Unsubscribe) Context() commands.CommandContext { + return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER +} + +// Aliases returns a list of aliases for the :unsubscribe command +func (Unsubscribe) Aliases() []string { + return []string{"unsubscribe"} +} + +// Execute runs the Unsubscribe command +func (u Unsubscribe) Execute(args []string) error { + editHeaders := (config.Compose.EditHeaders || u.Edit) && !u.NoEdit + + widget := app.SelectedTabContent().(app.ProvidesMessage) + msg, err := widget.SelectedMessage() + if err != nil { + return err + } + headers := msg.RFC822Headers + if !headers.Has("list-unsubscribe") { + return errors.New("No List-Unsubscribe header found") + } + text, err := headers.Text("list-unsubscribe") + if err != nil { + return err + } + methods := parseUnsubscribeMethods(text) + if len(methods) == 0 { + return fmt.Errorf("no methods found to unsubscribe") + } + log.Debugf("unsubscribe: found %d methods", len(methods)) + + unsubscribe := func(method *url.URL) { + log.Debugf("unsubscribe: trying to unsubscribe using %s", method.Scheme) + var err error + switch strings.ToLower(method.Scheme) { + case "mailto": + err = unsubscribeMailto(method, editHeaders, u.SkipEditor) + case "http", "https": + err = unsubscribeHTTP(method) + default: + err = fmt.Errorf("unsubscribe: skipping unrecognized scheme: %s", method.Scheme) + } + if err != nil { + app.PushError(err.Error()) + } + } + + var title string = "Select method to unsubscribe" + if msg != nil && msg.Envelope != nil && len(msg.Envelope.From) > 0 { + title = fmt.Sprintf("%s from %s", title, msg.Envelope.From[0]) + } + + options := make([]string, len(methods)) + for i, method := range methods { + options[i] = method.Scheme + } + + dialog := app.NewSelectorDialog( + title, + "Press <Enter> to confirm or <ESC> to cancel", + options, 0, app.SelectedAccountUiConfig(), + func(option string, err error) { + app.CloseDialog() + if err != nil { + if errors.Is(err, app.ErrNoOptionSelected) { + app.PushStatus("Unsubscribe: "+err.Error(), + 5*time.Second) + } else { + app.PushError("Unsubscribe: " + err.Error()) + } + return + } + for _, m := range methods { + if m.Scheme == option { + unsubscribe(m) + return + } + } + app.PushError("Unsubscribe: selected method not found") + }, + ) + app.AddDialog(dialog) + + return nil +} + +// parseUnsubscribeMethods reads the list-unsubscribe header and parses it as a +// list of angle-bracket <> deliminated URLs. See RFC 2369. +func parseUnsubscribeMethods(header string) (methods []*url.URL) { + r := bufio.NewReader(strings.NewReader(header)) + for { + // discard until < + _, err := r.ReadSlice('<') + if err != nil { + return + } + // read until < + m, err := r.ReadSlice('>') + if err != nil { + return + } + m = m[:len(m)-1] + if u, err := url.Parse(string(m)); err == nil { + methods = append(methods, u) + } + } +} + +func unsubscribeMailto(u *url.URL, editHeaders, skipEditor bool) error { + widget := app.SelectedTabContent().(app.ProvidesMessage) + acct := widget.SelectedAccount() + if acct == nil { + return errors.New("No account selected") + } + + h := &mail.Header{} + h.SetSubject(u.Query().Get("subject")) + if to, err := mail.ParseAddressList(u.Opaque); err == nil { + h.SetAddressList("to", to) + } + + composer, err := app.NewComposer( + acct, + acct.AccountConfig(), + acct.Worker(), + editHeaders, + "", + h, + nil, + strings.NewReader(u.Query().Get("body")), + ) + if err != nil { + return err + } + composer.Tab = app.NewTab(composer, "unsubscribe") + if skipEditor { + composer.Terminal().Close() + } else { + composer.FocusTerminal() + } + return nil +} + +func unsubscribeHTTP(u *url.URL) error { + confirm := app.NewSelectorDialog( + "Do you want to open this link?", + u.String(), + []string{"No", "Yes"}, 0, app.SelectedAccountUiConfig(), + func(option string, _ error) { + app.CloseDialog() + switch option { + case "Yes": + go func() { + defer log.PanicHandler() + mime := fmt.Sprintf("x-scheme-handler/%s", u.Scheme) + if err := lib.XDGOpenMime(u.String(), mime, ""); err != nil { + app.PushError("Unsubscribe:" + err.Error()) + } + }() + default: + app.PushError("Unsubscribe: link will not be opened") + } + }, + ) + app.AddDialog(confirm) + return nil +} diff --git a/commands/msg/unsubscribe_test.go b/commands/msg/unsubscribe_test.go new file mode 100644 index 0000000..4613830 --- /dev/null +++ b/commands/msg/unsubscribe_test.go @@ -0,0 +1,43 @@ +package msg + +import ( + "testing" +) + +func TestParseUnsubscribe(t *testing.T) { + type tc struct { + hdr string + expected []string + } + cases := []*tc{ + {"", []string{}}, + {"invalid", []string{}}, + {"<https://example.com>, <http://example.com>", []string{ + "https://example.com", "http://example.com", + }}, + {"<https://example.com> is a URL", []string{ + "https://example.com", + }}, + { + "<mailto:user@host?subject=unsubscribe>, <https://example.com>", + []string{ + "mailto:user@host?subject=unsubscribe", "https://example.com", + }, + }, + {"<>, <https://example> ", []string{ + "", "https://example", + }}, + } + for _, c := range cases { + result := parseUnsubscribeMethods(c.hdr) + if len(result) != len(c.expected) { + t.Errorf("expected %d methods but got %d", len(c.expected), len(result)) + continue + } + for idx := 0; idx < len(result); idx++ { + if result[idx].String() != c.expected[idx] { + t.Errorf("expected %v but got %v", c.expected[idx], result[idx]) + } + } + } +} diff --git a/commands/msg/utils.go b/commands/msg/utils.go new file mode 100644 index 0000000..f6acb10 --- /dev/null +++ b/commands/msg/utils.go @@ -0,0 +1,76 @@ +package msg + +import ( + "errors" + "time" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/lib" + "git.sr.ht/~rjarry/aerc/models" +) + +type helper struct { + msgProvider app.ProvidesMessages + statusInfo func(string) +} + +func newHelper() *helper { + msgProvider, ok := app.SelectedTabContent().(app.ProvidesMessages) + if !ok { + msgProvider = app.SelectedAccount() + } + return &helper{ + msgProvider: msgProvider, + statusInfo: func(s string) { + app.PushStatus(s, 10*time.Second) + }, + } +} + +func (h *helper) markedOrSelectedUids() ([]models.UID, error) { + return commands.MarkedOrSelected(h.msgProvider) +} + +func (h *helper) store() (*lib.MessageStore, error) { + store := h.msgProvider.Store() + if store == nil { + return nil, errors.New("Cannot perform action. Messages still loading") + } + return store, nil +} + +func (h *helper) account() (*app.AccountView, error) { + acct := h.msgProvider.SelectedAccount() + if acct == nil { + return nil, errors.New("No account selected") + } + return acct, nil +} + +func (h *helper) messages() ([]*models.MessageInfo, error) { + uid, err := commands.MarkedOrSelected(h.msgProvider) + if err != nil { + return nil, err + } + store, err := h.store() + if err != nil { + return nil, err + } + return commands.MsgInfoFromUids(store, uid, h.statusInfo) +} + +func getMessagePart(msg *models.MessageInfo, provider app.ProvidesMessage) []int { + p := provider.SelectedMessagePart() + if p != nil { + return p.Index + } + for _, mime := range config.Viewer.Alternatives { + part := lib.FindMIMEPart(mime, msg.BodyStructure, nil) + if part != nil { + return part + } + } + return nil +} diff --git a/commands/msgview/next-part.go b/commands/msgview/next-part.go new file mode 100644 index 0000000..5f1a8fb --- /dev/null +++ b/commands/msgview/next-part.go @@ -0,0 +1,38 @@ +package msgview + +import ( + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" +) + +type NextPrevPart struct { + Offset int `opt:"n" default:"1"` +} + +func init() { + commands.Register(NextPrevPart{}) +} + +func (NextPrevPart) Description() string { + return "Cycle between message parts being shown." +} + +func (NextPrevPart) Context() commands.CommandContext { + return commands.MESSAGE_VIEWER +} + +func (NextPrevPart) Aliases() []string { + return []string{"next-part", "prev-part"} +} + +func (np NextPrevPart) Execute(args []string) error { + mv, _ := app.SelectedTabContent().(*app.MessageViewer) + for n := 0; n < np.Offset; n++ { + if args[0] == "prev-part" { + mv.PreviousPart() + } else { + mv.NextPart() + } + } + return nil +} diff --git a/commands/msgview/open-link.go b/commands/msgview/open-link.go new file mode 100644 index 0000000..00cc931 --- /dev/null +++ b/commands/msgview/open-link.go @@ -0,0 +1,62 @@ +package msgview + +import ( + "fmt" + "net/url" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/lib" + "git.sr.ht/~rjarry/aerc/lib/log" +) + +type OpenLink struct { + Url *url.URL `opt:"url" action:"ParseUrl" complete:"CompleteUrl"` + Cmd string `opt:"..." required:"false"` +} + +func init() { + commands.Register(OpenLink{}) +} + +func (OpenLink) Description() string { + return "Open the specified URL with an external program." +} + +func (OpenLink) Context() commands.CommandContext { + return commands.MESSAGE_VIEWER +} + +func (OpenLink) Aliases() []string { + return []string{"open-link"} +} + +func (*OpenLink) CompleteUrl(arg string) []string { + mv := app.SelectedTabContent().(*app.MessageViewer) + if mv != nil { + if p := mv.SelectedMessagePart(); p != nil { + return commands.FilterList(p.Links, arg, nil) + } + } + return nil +} + +func (o *OpenLink) ParseUrl(arg string) error { + u, err := url.Parse(arg) + if err != nil { + return err + } + o.Url = u + return nil +} + +func (o OpenLink) Execute(args []string) error { + mime := fmt.Sprintf("x-scheme-handler/%s", o.Url.Scheme) + go func() { + defer log.PanicHandler() + if err := lib.XDGOpenMime(o.Url.String(), mime, o.Cmd); err != nil { + app.PushError("open-link: " + err.Error()) + } + }() + return nil +} diff --git a/commands/msgview/open.go b/commands/msgview/open.go new file mode 100644 index 0000000..7c770d4 --- /dev/null +++ b/commands/msgview/open.go @@ -0,0 +1,96 @@ +package msgview + +import ( + "errors" + "io" + "mime" + "os" + "path" + "path/filepath" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/lib" + "git.sr.ht/~rjarry/aerc/lib/log" +) + +type Open struct { + Delete bool `opt:"-d" desc:"Delete temp file after the opener exits."` + Cmd string `opt:"..." required:"false"` +} + +func init() { + commands.Register(Open{}) +} + +func (Open) Description() string { + return "Save the current message part to a temporary file, then open it." +} + +func (Open) Context() commands.CommandContext { + return commands.MESSAGE_VIEWER +} + +func (Open) Aliases() []string { + return []string{"open"} +} + +func (o Open) Execute(args []string) error { + mv := app.SelectedTabContent().(*app.MessageViewer) + if mv == nil { + return errors.New("open only supported selected message parts") + } + p := mv.SelectedMessagePart() + + mv.MessageView().FetchBodyPart(p.Index, func(reader io.Reader) { + mimeType := "" + + part, err := mv.MessageView().BodyStructure().PartAtIndex(p.Index) + if err != nil { + app.PushError(err.Error()) + return + } + mimeType = part.FullMIMEType() + + tmpDir, err := os.MkdirTemp(os.TempDir(), "aerc-*") + if err != nil { + app.PushError(err.Error()) + return + } + filename := path.Base(part.FileName()) + var tmpFile *os.File + if filename == "." { + extension := "" + if exts, _ := mime.ExtensionsByType(mimeType); len(exts) > 0 { + extension = exts[0] + } + tmpFile, err = os.CreateTemp(tmpDir, "aerc-*"+extension) + } else { + tmpFile, err = os.Create(filepath.Join(tmpDir, filename)) + } + if err != nil { + app.PushError(err.Error()) + return + } + + _, err = io.Copy(tmpFile, reader) + tmpFile.Close() + if err != nil { + app.PushError(err.Error()) + return + } + + go func() { + defer log.PanicHandler() + if o.Delete { + defer os.RemoveAll(tmpDir) + } + err = lib.XDGOpenMime(tmpFile.Name(), mimeType, o.Cmd) + if err != nil { + app.PushError("open: " + err.Error()) + } + }() + }) + + return nil +} diff --git a/commands/msgview/save.go b/commands/msgview/save.go new file mode 100644 index 0000000..349a823 --- /dev/null +++ b/commands/msgview/save.go @@ -0,0 +1,210 @@ +package msgview + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/xdg" + "git.sr.ht/~rjarry/aerc/models" +) + +type Save struct { + Force bool `opt:"-f" desc:"Overwrite destination path."` + CreateDirs bool `opt:"-p" desc:"Create missing directories."` + Attachments bool `opt:"-a" desc:"Save all attachments parts."` + AllAttachments bool `opt:"-A" desc:"Save all named parts."` + Path string `opt:"path" required:"false" complete:"CompletePath" desc:"Target file path."` +} + +func init() { + commands.Register(Save{}) +} + +func (Save) Description() string { + return "Save the current message part to the given path." +} + +func (Save) Context() commands.CommandContext { + return commands.MESSAGE_VIEWER +} + +func (Save) Aliases() []string { + return []string{"save"} +} + +func (*Save) CompletePath(arg string) []string { + defaultPath := config.General.DefaultSavePath + if defaultPath != "" && !isAbsPath(arg) { + arg = filepath.Join(defaultPath, arg) + } + return commands.CompletePath(arg, false) +} + +func (s Save) Execute(args []string) error { + // we either need a path or a defaultPath + if s.Path == "" && config.General.DefaultSavePath == "" { + return errors.New("No default save path in config") + } + + // Absolute paths are taken as is so that the user can override the default + // if they want to + if !isAbsPath(s.Path) { + s.Path = filepath.Join(config.General.DefaultSavePath, s.Path) + } + + s.Path = xdg.ExpandHome(s.Path) + + mv, ok := app.SelectedTabContent().(*app.MessageViewer) + if !ok { + return fmt.Errorf("SelectedTabContent is not a MessageViewer") + } + + if s.Attachments || s.AllAttachments { + parts := mv.AttachmentParts(s.AllAttachments) + if len(parts) == 0 { + return fmt.Errorf("This message has no attachments") + } + names := make(map[string]struct{}) + for _, pi := range parts { + if err := s.savePart(pi, mv, names); err != nil { + return err + } + } + return nil + } + + pi := mv.SelectedMessagePart() + return s.savePart(pi, mv, make(map[string]struct{})) +} + +func (s *Save) savePart( + pi *app.PartInfo, + mv *app.MessageViewer, + names map[string]struct{}, +) error { + path := s.Path + if s.Attachments || s.AllAttachments || isDirExists(path) { + filename := generateFilename(pi.Part) + path = filepath.Join(path, filename) + } + + dir := filepath.Dir(path) + if s.CreateDirs && dir != "" { + err := os.MkdirAll(dir, 0o755) + if err != nil { + return err + } + } + + path = getCollisionlessFilename(path, names) + names[path] = struct{}{} + + if pathExists(path) && !s.Force { + return fmt.Errorf("%q already exists and -f not given", path) + } + + ch := make(chan error, 1) + mv.MessageView().FetchBodyPart(pi.Index, func(reader io.Reader) { + f, err := os.Create(path) + if err != nil { + ch <- err + return + } + defer f.Close() + _, err = io.Copy(f, reader) + if err != nil { + ch <- err + return + } + ch <- nil + }) + + // we need to wait for the callback prior to displaying a result + go func() { + defer log.PanicHandler() + + err := <-ch + if err != nil { + app.PushError(fmt.Sprintf("Save failed: %v", err)) + return + } + app.PushStatus("Saved to "+path, 10*time.Second) + }() + return nil +} + +func getCollisionlessFilename(path string, existing map[string]struct{}) string { + ext := filepath.Ext(path) + name := strings.TrimSuffix(path, ext) + _, exists := existing[path] + counter := 1 + for exists { + path = fmt.Sprintf("%s_%d%s", name, counter, ext) + counter++ + _, exists = existing[path] + } + return path +} + +// isDir returns true if path is a directory and exists +func isDirExists(path string) bool { + pathinfo, err := os.Stat(path) + if err != nil { + return false // we don't really care + } + if pathinfo.IsDir() { + return true + } + return false +} + +// pathExists returns true if path exists +func pathExists(path string) bool { + _, err := os.Stat(path) + + return err == nil +} + +// isAbsPath returns true if path given is anchored to / or . or ~ +func isAbsPath(path string) bool { + if len(path) == 0 { + return false + } + switch path[0] { + case '/': + return true + case '.': + return true + case '~': + return true + default: + return false + } +} + +// generateFilename tries to get the filename from the given part. +// if that fails it will fallback to a generated one based on the date +func generateFilename(part *models.BodyStructure) string { + filename := part.FileName() + // Some MUAs send attachments with names like /some/stupid/idea/happy.jpeg + // Assuming non hostile intent it does make sense to use just the last + // portion of the pathname as the filename for saving it. + filename = filename[strings.LastIndex(filename, "/")+1:] + switch filename { + case "", ".", "..": + timestamp := time.Now().Format("2006-01-02-150405") + filename = fmt.Sprintf("aerc_%v", timestamp) + default: + // already have a valid name + } + return filename +} diff --git a/commands/msgview/save_test.go b/commands/msgview/save_test.go new file mode 100644 index 0000000..d6b7e75 --- /dev/null +++ b/commands/msgview/save_test.go @@ -0,0 +1,24 @@ +package msgview + +import "testing" + +func TestGetCollisionlessFilename(t *testing.T) { + tests := []struct { + originalFilename string + expectedNewName string + existingFiles map[string]struct{} + }{ + {"test", "test", map[string]struct{}{}}, + {"test", "test", map[string]struct{}{"other-file": {}}}, + {"test.txt", "test.txt", map[string]struct{}{"test.log": {}}}, + {"test.txt", "test_1.txt", map[string]struct{}{"test.txt": {}}}, + {"test.txt", "test_2.txt", map[string]struct{}{"test.txt": {}, "test_1.txt": {}}}, + {"test.txt", "test_1.txt", map[string]struct{}{"test.txt": {}, "test_2.txt": {}}}, + } + for _, tt := range tests { + actual := getCollisionlessFilename(tt.originalFilename, tt.existingFiles) + if actual != tt.expectedNewName { + t.Errorf("expected %s, actual %s", tt.expectedNewName, actual) + } + } +} diff --git a/commands/msgview/toggle-headers.go b/commands/msgview/toggle-headers.go new file mode 100644 index 0000000..c2b4e8f --- /dev/null +++ b/commands/msgview/toggle-headers.go @@ -0,0 +1,30 @@ +package msgview + +import ( + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" +) + +type ToggleHeaders struct{} + +func init() { + commands.Register(ToggleHeaders{}) +} + +func (ToggleHeaders) Description() string { + return "Toggle the visibility of message headers." +} + +func (ToggleHeaders) Context() commands.CommandContext { + return commands.MESSAGE_VIEWER +} + +func (ToggleHeaders) Aliases() []string { + return []string{"toggle-headers"} +} + +func (ToggleHeaders) Execute(args []string) error { + mv, _ := app.SelectedTabContent().(*app.MessageViewer) + mv.ToggleHeaders() + return nil +} diff --git a/commands/msgview/toggle-key-passthrough.go b/commands/msgview/toggle-key-passthrough.go new file mode 100644 index 0000000..c972d18 --- /dev/null +++ b/commands/msgview/toggle-key-passthrough.go @@ -0,0 +1,34 @@ +package msgview + +import ( + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/lib/state" +) + +type ToggleKeyPassthrough struct{} + +func init() { + commands.Register(ToggleKeyPassthrough{}) +} + +func (ToggleKeyPassthrough) Description() string { + return "Enter or exit the passthrough key bindings context." +} + +func (ToggleKeyPassthrough) Context() commands.CommandContext { + return commands.MESSAGE_VIEWER +} + +func (ToggleKeyPassthrough) Aliases() []string { + return []string{"toggle-key-passthrough"} +} + +func (ToggleKeyPassthrough) Execute(args []string) error { + mv, _ := app.SelectedTabContent().(*app.MessageViewer) + keyPassthroughEnabled := mv.ToggleKeyPassthrough() + if acct := mv.SelectedAccount(); acct != nil { + acct.SetStatus(state.Passthrough(keyPassthroughEnabled)) + } + return nil +} diff --git a/commands/new-account.go b/commands/new-account.go new file mode 100644 index 0000000..e0fe3b2 --- /dev/null +++ b/commands/new-account.go @@ -0,0 +1,33 @@ +package commands + +import ( + "git.sr.ht/~rjarry/aerc/app" +) + +type NewAccount struct { + Temp bool `opt:"-t" desc:"Create a temporary account."` +} + +func init() { + Register(NewAccount{}) +} + +func (NewAccount) Description() string { + return "Start the new account wizard." +} + +func (NewAccount) Context() CommandContext { + return GLOBAL +} + +func (NewAccount) Aliases() []string { + return []string{"new-account"} +} + +func (n NewAccount) Execute(args []string) error { + wizard := app.NewAccountWizard() + wizard.ConfigureTemporaryAccount(n.Temp) + wizard.Focus(true) + app.NewTab(wizard, "New account") + return nil +} diff --git a/commands/next-tab.go b/commands/next-tab.go new file mode 100644 index 0000000..466c4ff --- /dev/null +++ b/commands/next-tab.go @@ -0,0 +1,40 @@ +package commands + +import ( + "git.sr.ht/~rjarry/aerc/app" +) + +type NextPrevTab struct { + Offset int `opt:"n" default:"1"` +} + +func init() { + Register(NextPrevTab{}) +} + +func (NextPrevTab) Description() string { + return "Cycle to the previous or next tab." +} + +func (NextPrevTab) Context() CommandContext { + return GLOBAL +} + +func (NextPrevTab) Aliases() []string { + return []string{"next-tab", "prev-tab"} +} + +func (np NextPrevTab) Execute(args []string) error { + if np.Offset <= 0 { + return nil + } + + offset := np.Offset + if args[0] == "prev-tab" { + offset *= -1 + } + + app.SelectTabAtOffset(offset) + app.UpdateStatus() + return nil +} diff --git a/commands/patch/apply.go b/commands/patch/apply.go new file mode 100644 index 0000000..31ca696 --- /dev/null +++ b/commands/patch/apply.go @@ -0,0 +1,268 @@ +package patch + +import ( + "fmt" + "sort" + "strings" + "unicode" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/commands/msg" + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/pama" + "git.sr.ht/~rjarry/aerc/lib/pama/models" +) + +type Apply struct { + Cmd string `opt:"-c" desc:"Apply patches with provided command."` + Worktree string `opt:"-w" desc:"Create linked worktree on this <commit-ish>."` + Tag string `opt:"tag" required:"true" complete:"CompleteTag" desc:"Identify patches with tag."` +} + +func init() { + register(Apply{}) +} + +func (Apply) Description() string { + return "Apply the selected message(s) to the current project." +} + +func (Apply) Context() commands.CommandContext { + return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER +} + +func (Apply) Aliases() []string { + return []string{"apply"} +} + +func (*Apply) CompleteTag(arg string) []string { + patches, err := pama.New().CurrentPatches() + if err != nil { + log.Errorf("failed to current patches for completion: %v", err) + patches = nil + } + + acct := app.SelectedAccount() + if acct == nil { + return nil + } + + uids, err := acct.MarkedMessages() + if err != nil { + return nil + } + if len(uids) == 0 { + msg, err := acct.SelectedMessage() + if err == nil { + uids = append(uids, msg.Uid) + } + } + + store := acct.Store() + if store == nil { + return nil + } + + var subjects []string + for _, uid := range uids { + if msg, ok := store.Messages[uid]; !ok || msg == nil || msg.Envelope == nil { + continue + } else { + subjects = append(subjects, msg.Envelope.Subject) + } + } + return proposePatchName(patches, subjects) +} + +func (a Apply) Execute(args []string) error { + patch := a.Tag + worktree := a.Worktree + applyCmd := a.Cmd + + m := pama.New() + p, err := m.CurrentProject() + if err != nil { + return err + } + log.Tracef("Current project: %v", p) + + if worktree != "" { + p, err = m.CreateWorktree(p, worktree, patch) + if err != nil { + return err + } + err = m.SwitchProject(p.Name) + if err != nil { + log.Warnf("could not switch to worktree project: %v", err) + } + } + + if models.Commits(p.Commits).HasTag(patch) { + return fmt.Errorf("Patch name '%s' already exists.", patch) + } + + if !m.Clean(p) { + return fmt.Errorf("Aborting... There are unstaged changes in " + + "your repository.") + } + + commit, err := m.Head(p) + if err != nil { + return err + } + log.Tracef("HEAD commit before: %s", commit) + + if applyCmd != "" { + rootFmt := "%r" + if strings.Contains(applyCmd, rootFmt) { + applyCmd = strings.ReplaceAll(applyCmd, rootFmt, p.Root) + } + log.Infof("use custom apply command: %s", applyCmd) + } else { + applyCmd, err = m.ApplyCmd(p) + if err != nil { + return err + } + } + + msgData := collectMessageData() + + // apply patches with the pipe cmd + pipe := msg.Pipe{ + Background: false, + Full: true, + Part: false, + Command: applyCmd, + } + return pipe.Run(func() { + p, err = m.ApplyUpdate(p, patch, commit, msgData) + if err != nil { + log.Errorf("Failed to save patch data: %v", err) + } + }) +} + +// collectMessageData returns a map where the key is the message id and the +// value the subject of the marked messages +func collectMessageData() map[string]string { + acct := app.SelectedAccount() + if acct == nil { + return nil + } + + uids, err := commands.MarkedOrSelected(acct) + if err != nil { + log.Errorf("error occurred: %v", err) + return nil + } + + store := acct.Store() + if store == nil { + return nil + } + + kv := make(map[string]string) + for _, uid := range uids { + msginfo, ok := store.Messages[uid] + if !ok || msginfo == nil { + continue + } + id, err := msginfo.MsgId() + if err != nil { + continue + } + if msginfo.Envelope == nil { + continue + } + + kv[id] = msginfo.Envelope.Subject + } + + return kv +} + +func proposePatchName(patches, subjects []string) []string { + parse := func(s string) (string, string, bool) { + var tag strings.Builder + var version string + var i, j int + + i = strings.Index(s, "[") + if i < 0 { + goto noPatch + } + s = s[i+1:] + + j = strings.Index(s, "]") + if j < 0 { + goto noPatch + } + for _, elem := range strings.Fields(s[:j]) { + vers := strings.ToLower(elem) + if !strings.HasPrefix(vers, "v") { + continue + } + isVersion := true + for _, r := range vers[1:] { + if !unicode.IsDigit(r) { + isVersion = false + break + } + } + if isVersion { + version = vers + break + } + } + s = strings.TrimSpace(s[j+1:]) + + for _, r := range s { + if unicode.IsSpace(r) || r == ':' { + break + } + _, err := tag.WriteRune(r) + if err != nil { + continue + } + } + return tag.String(), version, true + noPatch: + return "", "", false + } + + summary := make(map[string]struct{}) + + var results []string + for _, s := range subjects { + tag, version, isPatch := parse(s) + if tag == "" || !isPatch { + continue + } + if version == "" { + version = "v1" + } + result := fmt.Sprintf("%s_%s", tag, version) + result = strings.ReplaceAll(result, " ", "") + + collision := false + for _, name := range patches { + if name == result { + collision = true + } + } + if collision { + continue + } + + _, ok := summary[result] + if ok { + continue + } + results = append(results, result) + summary[result] = struct{}{} + } + + sort.Strings(results) + return results +} diff --git a/commands/patch/apply_test.go b/commands/patch/apply_test.go new file mode 100644 index 0000000..12a87c7 --- /dev/null +++ b/commands/patch/apply_test.go @@ -0,0 +1,53 @@ +package patch + +import ( + "reflect" + "testing" +) + +func TestPatchApply_ProposeName(t *testing.T) { + tests := []struct { + name string + exist []string + subjects []string + want []string + }{ + { + name: "base case", + exist: nil, + subjects: []string{ + "[PATCH aerc v3 3/3] notmuch: remove unused code", + "[PATCH aerc v3 2/3] notmuch: replace notmuch library with internal bindings", + "[PATCH aerc v3 1/3] notmuch: add notmuch bindings", + }, + want: []string{"notmuch_v3"}, + }, + { + name: "distorted case", + exist: nil, + subjects: []string{ + "[PATCH vaerc v3 3/3] notmuch: remove unused code", + "[PATCH aerc 3v 2/3] notmuch: replace notmuch library with internal bindings", + }, + want: []string{"notmuch_v1", "notmuch_v3"}, + }, + { + name: "invalid patches", + exist: nil, + subjects: []string{ + "notmuch: remove unused code", + ": replace notmuch library with internal bindings", + }, + want: nil, + }, + } + + for _, test := range tests { + got := proposePatchName(test.exist, test.subjects) + if !reflect.DeepEqual(got, test.want) { + t.Errorf("test '%s' failed to propose the correct "+ + "name: got '%v', but want '%v'", test.name, + got, test.want) + } + } +} diff --git a/commands/patch/cd.go b/commands/patch/cd.go new file mode 100644 index 0000000..70e9241 --- /dev/null +++ b/commands/patch/cd.go @@ -0,0 +1,51 @@ +package patch + +import ( + "fmt" + "os" + "time" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/lib/pama" +) + +type Cd struct{} + +func init() { + register(Cd{}) +} + +func (Cd) Description() string { + return "Change aerc's working directory to the current project." +} + +func (Cd) Context() commands.CommandContext { + return commands.GLOBAL +} + +func (Cd) Aliases() []string { + return []string{"cd"} +} + +func (Cd) Execute(args []string) error { + p, err := pama.New().CurrentProject() + if err != nil { + return err + } + cwd, err := os.Getwd() + if err != nil { + return err + } + if cwd == p.Root { + app.PushStatus("Already here.", 10*time.Second) + return nil + } + err = os.Chdir(p.Root) + if err != nil { + return err + } + app.PushStatus(fmt.Sprintf("Changed to %s.", p.Root), + 10*time.Second) + return nil +} diff --git a/commands/patch/drop.go b/commands/patch/drop.go new file mode 100644 index 0000000..89d574f --- /dev/null +++ b/commands/patch/drop.go @@ -0,0 +1,51 @@ +package patch + +import ( + "fmt" + "time" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/pama" +) + +type Drop struct { + Tag string `opt:"tag" complete:"CompleteTag" desc:"Repository patch tag."` +} + +func init() { + register(Drop{}) +} + +func (Drop) Description() string { + return "Drop a patch from the repository." +} + +func (Drop) Context() commands.CommandContext { + return commands.GLOBAL +} + +func (Drop) Aliases() []string { + return []string{"drop"} +} + +func (*Drop) CompleteTag(arg string) []string { + patches, err := pama.New().CurrentPatches() + if err != nil { + log.Errorf("failed to get current patches: %v", err) + return nil + } + return commands.FilterList(patches, arg, nil) +} + +func (r Drop) Execute(args []string) error { + patch := r.Tag + err := pama.New().DropPatch(patch) + if err != nil { + return err + } + app.PushStatus(fmt.Sprintf("Patch %s has been dropped", patch), + 10*time.Second) + return nil +} diff --git a/commands/patch/find.go b/commands/patch/find.go new file mode 100644 index 0000000..ab5252c --- /dev/null +++ b/commands/patch/find.go @@ -0,0 +1,140 @@ +package patch + +import ( + "errors" + "fmt" + "net/textproto" + "strings" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/commands/account" + "git.sr.ht/~rjarry/aerc/lib/pama" + "git.sr.ht/~rjarry/aerc/lib/pama/models" + "git.sr.ht/~rjarry/go-opt/v2" +) + +type Find struct { + Filter bool `opt:"-f" desc:"Filter message list instead of search."` + Commit []string `opt:"..." required:"true" complete:"Complete" desc:"Search for <commit-ish>."` +} + +func init() { + register(Find{}) +} + +func (Find) Description() string { + return "Search for applied patches." +} + +func (Find) Context() commands.CommandContext { + return commands.GLOBAL +} + +func (Find) Aliases() []string { + return []string{"find"} +} + +func (*Find) Complete(arg string) []string { + m := pama.New() + p, err := m.CurrentProject() + if err != nil { + return nil + } + + options := make([]string, len(p.Commits)) + for i, c := range p.Commits { + options[i] = fmt.Sprintf("%-6.6s %s", c.ID, c.Subject) + } + + return commands.FilterList(options, arg, nil) +} + +func (s Find) Execute(_ []string) error { + m := pama.New() + p, err := m.CurrentProject() + if err != nil { + return err + } + + if len(s.Commit) == 0 { + return errors.New("missing commit hash") + } + + lexed := opt.LexArgs(strings.TrimSpace(s.Commit[0])) + + hash, err := lexed.ArgSafe(0) + if err != nil { + return err + } + + if len(hash) < 4 { + return errors.New("Commit hash is too short.") + } + + var c models.Commit + for _, commit := range p.Commits { + if strings.Contains(commit.ID, hash) { + c = commit + break + } + } + if c.ID == "" { + var err error + c, err = m.Find(hash, p) + if err != nil { + return err + } + } + + // If Message-Id is provided, find it in store + if c.MessageId != "" { + if selectMessageId(c.MessageId) { + return nil + } + } + + // Fallback to a search based on the subject line + args := []string{"search"} + if s.Filter { + args[0] = "filter" + } + + headers := make(textproto.MIMEHeader) + args = append(args, fmt.Sprintf("-H Subject:%s", c.Subject)) + headers.Add("Subject", c.Subject) + + cmd := account.SearchFilter{ + Headers: headers, + } + + return cmd.Execute(args) +} + +func selectMessageId(msgid string) bool { + acct := app.SelectedAccount() + if acct == nil { + return false + } + store := acct.Store() + if store == nil { + return false + } + for uid, msg := range store.Messages { + if msg == nil { + continue + } + if msg.RFC822Headers == nil { + continue + } + id, err := msg.RFC822Headers.MessageID() + if err != nil { + continue + } + if id == msgid { + store.Select(uid) + return true + } + } + return false +} diff --git a/commands/patch/init.go b/commands/patch/init.go new file mode 100644 index 0000000..7640f26 --- /dev/null +++ b/commands/patch/init.go @@ -0,0 +1,45 @@ +package patch + +import ( + "fmt" + "os" + "path/filepath" + + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/lib/pama" +) + +type Init struct { + Force bool `opt:"-f" desc:"Overwrite any existing project."` + Name string `opt:"name" required:"false"` +} + +func init() { + register(Init{}) +} + +func (Init) Description() string { + return "Create a new project." +} + +func (Init) Context() commands.CommandContext { + return commands.GLOBAL +} + +func (Init) Aliases() []string { + return []string{"init"} +} + +func (i Init) Execute(args []string) error { + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("Could not get current directory: %w", err) + } + + name := i.Name + if name == "" { + name = filepath.Base(cwd) + } + + return pama.New().Init(name, cwd, i.Force) +} diff --git a/commands/patch/list.go b/commands/patch/list.go new file mode 100644 index 0000000..f4a1e5e --- /dev/null +++ b/commands/patch/list.go @@ -0,0 +1,114 @@ +package patch + +import ( + "bufio" + "fmt" + "io" + "os/exec" + "time" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/lib/pama" + "git.sr.ht/~rjarry/aerc/lib/pama/models" + "git.sr.ht/~rjarry/aerc/lib/ui" + "git.sr.ht/~rjarry/go-opt/v2" + "git.sr.ht/~rockorager/vaxis" +) + +type List struct { + All bool `opt:"-a" desc:"List all projects."` +} + +func init() { + register(List{}) +} + +func (List) Description() string { + return "List the current project with the tracked patch sets." +} + +func (List) Context() commands.CommandContext { + return commands.GLOBAL +} + +func (List) Aliases() []string { + return []string{"list", "ls"} +} + +func (l List) Execute(args []string) error { + m := pama.New() + current, err := m.CurrentProject() + if err != nil { + return err + } + + projects := []models.Project{current} + if l.All { + projects, err = m.Projects("") + if err != nil { + return err + } + } + + app.PushStatus(fmt.Sprintf("Current project: %s", current.Name), 30*time.Second) + + createWidget := func(r io.Reader) (ui.DrawableInteractive, error) { + pagerCmd, err := app.CmdFallbackSearch(config.PagerCmds(), true) + if err != nil { + return nil, err + } + + cmd := opt.SplitArgs(pagerCmd) + pager := exec.Command(cmd[0], cmd[1:]...) + pager.Stdin = r + + term, err := app.NewTerminal(pager) + if err != nil { + return nil, err + } + start := time.Now() + term.OnClose = func(err error) { + if time.Since(start) > 250*time.Millisecond { + app.CloseDialog() + return + } + term.OnEvent = func(_ vaxis.Event) bool { + app.CloseDialog() + return true + } + } + return term, nil + } + + viewer, err := createWidget(m.NewReader(projects)) + if err != nil { + viewer = app.NewListBox( + "Press <Esc> or <Enter> to close. "+ + "Start typing to filter.", + numerify(m.NewReader(projects)), app.SelectedAccountUiConfig(), + func(_ string) { app.CloseDialog() }, + ) + } + + app.AddDialog(app.DefaultDialog( + ui.NewBox(viewer, "Patch Management", "", + app.SelectedAccountUiConfig(), + ), + )) + + return nil +} + +func numerify(r io.Reader) []string { + var lines []string + nr := 1 + scanner := bufio.NewScanner(r) + for scanner.Scan() { + s := scanner.Text() + lines = append(lines, fmt.Sprintf("%3d %s", nr, s)) + nr++ + } + return lines +} diff --git a/commands/patch/patch.go b/commands/patch/patch.go new file mode 100644 index 0000000..8e28039 --- /dev/null +++ b/commands/patch/patch.go @@ -0,0 +1,88 @@ +package patch + +import ( + "errors" + "fmt" + + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/go-opt/v2" +) + +var subCommands map[string]commands.Command + +func register(cmd commands.Command) { + if subCommands == nil { + subCommands = make(map[string]commands.Command) + } + for _, alias := range cmd.Aliases() { + if subCommands[alias] != nil { + panic("duplicate sub command alias: " + alias) + } + subCommands[alias] = cmd + } +} + +type Patch struct { + SubCmd commands.Command `opt:"command" action:"ParseSub" complete:"CompleteSubNames" desc:"Sub command."` + Args string `opt:"..." required:"false" complete:"CompleteSubArgs"` +} + +func init() { + commands.Register(Patch{}) +} + +func (Patch) Description() string { + return "Local patch management commands." +} + +func (Patch) Context() commands.CommandContext { + return commands.GLOBAL +} + +func (Patch) Aliases() []string { + return []string{"patch"} +} + +func (p *Patch) ParseSub(arg string) error { + cmd, ok := subCommands[arg] + if ok { + context := commands.CurrentContext() + if cmd.Context()&context != 0 { + p.SubCmd = cmd + return nil + } + } + return fmt.Errorf("%s unknown sub-command", arg) +} + +func (*Patch) CompleteSubNames(arg string) []string { + context := commands.CurrentContext() + options := make([]string, 0, len(subCommands)) + for alias, cmd := range subCommands { + if cmd.Context()&context != 0 { + options = append(options, alias) + } + } + return commands.FilterList(options, arg, commands.QuoteSpace) +} + +func (p *Patch) CompleteSubArgs(arg string) []string { + if p.SubCmd == nil { + return nil + } + // prepend arbitrary string to arg to work with sub-commands + options, _ := commands.GetCompletions(p.SubCmd, opt.LexArgs("a "+arg)) + completions := make([]string, 0, len(options)) + for _, o := range options { + completions = append(completions, o.Value) + } + return completions +} + +func (p Patch) Execute(args []string) error { + if p.SubCmd == nil { + return errors.New("no subcommand found") + } + a := opt.QuoteArgs(args[1:]...) + return commands.ExecuteCommand(p.SubCmd, a.String()) +} diff --git a/commands/patch/rebase.go b/commands/patch/rebase.go new file mode 100644 index 0000000..26b82f6 --- /dev/null +++ b/commands/patch/rebase.go @@ -0,0 +1,250 @@ +package patch + +import ( + "bufio" + "bytes" + "fmt" + "io" + "os" + "os/exec" + "sort" + "strings" + "time" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/pama" + "git.sr.ht/~rjarry/aerc/lib/pama/models" + "git.sr.ht/~rjarry/aerc/lib/ui" +) + +type Rebase struct { + Commit string `opt:"commit" required:"false"` +} + +func init() { + register(Rebase{}) +} + +func (Rebase) Description() string { + return "Rebase the patch data." +} + +func (Rebase) Context() commands.CommandContext { + return commands.GLOBAL +} + +func (Rebase) Aliases() []string { + return []string{"rebase"} +} + +func (r Rebase) Execute(args []string) error { + m := pama.New() + current, err := m.CurrentProject() + if err != nil { + return err + } + + baseID := r.Commit + if baseID == "" { + baseID = current.Base.ID + } + + commits, err := m.RebaseCommits(current, baseID) + if err != nil { + return err + } + + if len(commits) == 0 { + err := m.SaveRebased(current, baseID, nil) + if err != nil { + return fmt.Errorf("No commits to rebase, but saving of new reference failed: %w", err) + } + app.PushStatus("No commits to rebase.", 10*time.Second) + return nil + } + + rebase := newRebase(commits) + f, err := os.CreateTemp("", "aerc-patch-rebase-*") + if err != nil { + return err + } + name := f.Name() + _, err = io.Copy(f, rebase.content()) + if err != nil { + return err + } + f.Close() + + createWidget := func() (ui.DrawableInteractive, error) { + editorCmd, err := app.CmdFallbackSearch(config.EditorCmds(), true) + if err != nil { + return nil, err + } + editor := exec.Command("/bin/sh", "-c", editorCmd+" "+name) + term, err := app.NewTerminal(editor) + if err != nil { + return nil, err + } + term.OnClose = func(_ error) { + app.CloseDialog() + defer os.Remove(name) + defer term.Focus(false) + + f, err := os.Open(name) + if err != nil { + app.PushError(fmt.Sprintf("failed to open file: %v", err)) + return + } + defer f.Close() + + if editor.ProcessState.ExitCode() > 0 { + app.PushError("Quitting rebase without saving.") + return + } + err = m.SaveRebased(current, baseID, rebase.parse(f)) + if err != nil { + app.PushError(fmt.Sprintf("Failed to save rebased commits: %v", err)) + return + } + app.PushStatus("Successfully rebased.", 10*time.Second) + } + term.Show(true) + term.Focus(true) + return term, nil + } + + viewer, err := createWidget() + if err != nil { + return err + } + + app.AddDialog(app.DefaultDialog( + ui.NewBox(viewer, fmt.Sprintf("Patch Rebase on %-6.6s", baseID), "", + app.SelectedAccountUiConfig(), + ), + )) + + return nil +} + +type rebase struct { + commits []models.Commit + table map[string]models.Commit + order []string +} + +func newRebase(commits []models.Commit) *rebase { + return &rebase{ + commits: commits, + table: make(map[string]models.Commit), + } +} + +const ( + header string = "" + footer string = ` +# Rebase aerc's patch data. This will not affect the underlying repository in +# any way. +# +# Change the name in the first column to assign a new tag to a commit. To group +# multiple commits, use the same tag name. +# +# An 'untracked' tag indicates that aerc lost track of that commit, either due +# to a commit-hash change or because that commit was applied outside of aerc. +# +# Do not change anything else besides the tag names (first column). +# +# Do not reorder the lines. The ordering should remain as in the repository. +# +# If you remove a line or keep an 'untracked' tag, those commits will be removed +# from aerc's patch tracking. +# +` +) + +func (r *rebase) content() io.Reader { + var buf bytes.Buffer + buf.WriteString(header) + for _, c := range r.commits { + tag := c.Tag + if tag == "" { + tag = models.Untracked + } + shortHash := fmt.Sprintf("%6.6s", c.ID) + buf.WriteString( + fmt.Sprintf("%-12s %6.6s %s\n", tag, shortHash, c.Info())) + r.table[shortHash] = c + r.order = append(r.order, shortHash) + } + buf.WriteString(footer) + return &buf +} + +func (r *rebase) parse(reader io.Reader) []models.Commit { + var commits []models.Commit + var hashes []string + scanner := bufio.NewScanner(reader) + duplicated := make(map[string]struct{}) + for scanner.Scan() { + s := scanner.Text() + i := strings.Index(s, "#") + if i >= 0 { + s = s[:i] + } + if strings.TrimSpace(s) == "" { + continue + } + + fds := strings.Fields(s) + if len(fds) < 2 { + continue + } + + tag, shortHash := fds[0], fds[1] + if tag == models.Untracked { + continue + } + _, dedup := duplicated[shortHash] + if dedup { + log.Warnf("rebase: skipping duplicated hash: %s", shortHash) + continue + } + + hashes = append(hashes, shortHash) + c, ok := r.table[shortHash] + if !ok { + log.Errorf("Looks like the commit hashes were changed "+ + "during the rebase. Dropping: %v", shortHash) + continue + } + log.Tracef("save commit %s with tag %s", shortHash, tag) + c.Tag = tag + commits = append(commits, c) + duplicated[shortHash] = struct{}{} + } + reorder(commits, hashes, r.order) + return commits +} + +func reorder(toSort []models.Commit, now []string, by []string) { + byMap := make(map[string]int) + for i, s := range by { + byMap[s] = i + } + + complete := true + for _, s := range now { + _, ok := byMap[s] + complete = complete && ok + } + if !complete { + return + } + + sort.SliceStable(toSort, func(i, j int) bool { + return byMap[now[i]] < byMap[now[j]] + }) +} diff --git a/commands/patch/rebase_test.go b/commands/patch/rebase_test.go new file mode 100644 index 0000000..fd3d705 --- /dev/null +++ b/commands/patch/rebase_test.go @@ -0,0 +1,114 @@ +package patch + +import ( + "fmt" + "reflect" + "strings" + "testing" + + "git.sr.ht/~rjarry/aerc/lib/pama/models" +) + +func TestRebase_reorder(t *testing.T) { + newCommits := func(order []string) []models.Commit { + var commits []models.Commit + for _, s := range order { + commits = append(commits, models.Commit{ID: s}) + } + return commits + } + tests := []struct { + name string + commits []models.Commit + now []string + by []string + want []models.Commit + }{ + { + name: "nothing to reorder", + commits: newCommits([]string{"1", "2", "3"}), + now: []string{"1", "2", "3"}, + by: []string{"1", "2", "3"}, + want: newCommits([]string{"1", "2", "3"}), + }, + { + name: "reorder", + commits: newCommits([]string{"1", "3", "2"}), + now: []string{"1", "3", "2"}, + by: []string{"1", "2", "3"}, + want: newCommits([]string{"1", "2", "3"}), + }, + { + name: "reorder inverted", + commits: newCommits([]string{"3", "2", "1"}), + now: []string{"3", "2", "1"}, + by: []string{"1", "2", "3"}, + want: newCommits([]string{"1", "2", "3"}), + }, + { + name: "changed hash: do not sort", + commits: newCommits([]string{"1", "6", "3"}), + now: []string{"1", "6", "3"}, + by: []string{"1", "2", "3"}, + want: newCommits([]string{"1", "6", "3"}), + }, + } + + for _, test := range tests { + reorder(test.commits, test.now, test.by) + if !reflect.DeepEqual(test.commits, test.want) { + t.Errorf("test '%s' failed to reorder: got %v but "+ + "want %v", test.name, test.commits, test.want) + } + } +} + +func newCommit(id, subj, tag string) models.Commit { + return models.Commit{ + ID: id, + Subject: subj, + Tag: tag, + } +} + +func TestRebase_parse(t *testing.T) { + input := ` + # some header info + hello_v1 123 same info + hello_v1 456 same info + untracked 789 same info + hello_v2 012 diff info + untracked 345 diff info # not very useful comment + # some footer info + ` + commits := []models.Commit{ + newCommit("123123", "same info", "hello_v1"), + newCommit("456456", "same info", "hello_v1"), + newCommit("789789", "same info", models.Untracked), + newCommit("012012", "diff info", "hello_v2"), + newCommit("345345", "diff info", models.Untracked), + } + + var order []string + for _, c := range commits { + order = append(order, fmt.Sprintf("%3.3s", c.ID)) + } + + table := make(map[string]models.Commit) + for i, shortId := range order { + table[shortId] = commits[i] + } + + rebase := &rebase{ + commits: commits, + table: table, + order: order, + } + + results := rebase.parse(strings.NewReader(input)) + + if len(results) != 3 { + t.Errorf("failed to return correct number of commits: "+ + "got %d but wanted 3", len(results)) + } +} diff --git a/commands/patch/switch.go b/commands/patch/switch.go new file mode 100644 index 0000000..3eea126 --- /dev/null +++ b/commands/patch/switch.go @@ -0,0 +1,62 @@ +package patch + +import ( + "fmt" + "time" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/pama" +) + +type Switch struct { + Project string `opt:"project" complete:"Complete" desc:"Project name."` +} + +func init() { + register(Switch{}) +} + +func (Switch) Description() string { + return "Switch context to the specified project." +} + +func (Switch) Context() commands.CommandContext { + return commands.GLOBAL +} + +func (Switch) Aliases() []string { + return []string{"switch"} +} + +func (s Switch) Complete(arg string) []string { + m := pama.New() + names, err := m.Names() + if err != nil { + log.Errorf("failed to get completion: %v", err) + return nil + } + cur, err := m.CurrentProject() + if err == nil { + i := 0 + for ; i < len(names); i++ { + if cur.Name == names[i] { + names = append(names[:i], names[i+1:]...) + break + } + } + } + return commands.FilterList(names, arg, nil) +} + +func (s Switch) Execute(_ []string) error { + name := s.Project + err := pama.New().SwitchProject(name) + if err != nil { + return err + } + app.PushStatus(fmt.Sprintf("Project switched to '%s'", name), + 10*time.Second) + return nil +} diff --git a/commands/patch/term.go b/commands/patch/term.go new file mode 100644 index 0000000..c81f699 --- /dev/null +++ b/commands/patch/term.go @@ -0,0 +1,34 @@ +package patch + +import ( + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/lib/pama" +) + +type Term struct { + Cmd []string `opt:"..." required:"false"` +} + +func init() { + register(Term{}) +} + +func (Term) Description() string { + return "Open a shell or run a command in the current project's directory." +} + +func (Term) Context() commands.CommandContext { + return commands.GLOBAL +} + +func (Term) Aliases() []string { + return []string{"term"} +} + +func (t Term) Execute(_ []string) error { + p, err := pama.New().CurrentProject() + if err != nil { + return err + } + return commands.TermCoreDirectory(t.Cmd, p.Root) +} diff --git a/commands/patch/unlink.go b/commands/patch/unlink.go new file mode 100644 index 0000000..5c47d89 --- /dev/null +++ b/commands/patch/unlink.go @@ -0,0 +1,62 @@ +package patch + +import ( + "fmt" + "time" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/pama" +) + +type Unlink struct { + Tag string `opt:"tag" required:"false" complete:"Complete" desc:"Project tag name."` +} + +func init() { + register(Unlink{}) +} + +func (Unlink) Description() string { + return "Delete all patch tracking data for the specified project." +} + +func (Unlink) Context() commands.CommandContext { + return commands.GLOBAL +} + +func (Unlink) Aliases() []string { + return []string{"unlink"} +} + +func (*Unlink) Complete(arg string) []string { + names, err := pama.New().Names() + if err != nil { + log.Errorf("failed to get completion: %v", err) + return nil + } + return commands.FilterList(names, arg, nil) +} + +func (d Unlink) Execute(args []string) error { + m := pama.New() + + name := d.Tag + if name == "" { + p, err := m.CurrentProject() + if err != nil { + return err + } + name = p.Name + } + + err := m.Unlink(name) + if err != nil { + return err + } + + app.PushStatus(fmt.Sprintf("Project '%s' unlinked.", name), + 10*time.Second) + return nil +} diff --git a/commands/pin-tab.go b/commands/pin-tab.go new file mode 100644 index 0000000..8ec6d24 --- /dev/null +++ b/commands/pin-tab.go @@ -0,0 +1,34 @@ +package commands + +import ( + "git.sr.ht/~rjarry/aerc/app" +) + +type PinTab struct{} + +func init() { + Register(PinTab{}) +} + +func (PinTab) Description() string { + return "Move the current tab to the left and mark it as pinned." +} + +func (PinTab) Context() CommandContext { + return GLOBAL +} + +func (PinTab) Aliases() []string { + return []string{"pin-tab", "unpin-tab"} +} + +func (PinTab) Execute(args []string) error { + switch args[0] { + case "pin-tab": + app.PinTab() + case "unpin-tab": + app.UnpinTab() + } + + return nil +} diff --git a/commands/prompt.go b/commands/prompt.go new file mode 100644 index 0000000..8d87446 --- /dev/null +++ b/commands/prompt.go @@ -0,0 +1,38 @@ +package commands + +import ( + "git.sr.ht/~rjarry/go-opt/v2" + + "git.sr.ht/~rjarry/aerc/app" +) + +type Prompt struct { + Text string `opt:"text"` + Cmd []string `opt:"..." complete:"CompleteCommand" desc:"Command name."` +} + +func init() { + Register(Prompt{}) +} + +func (Prompt) Description() string { + return "Prompt for user input and execute a command." +} + +func (Prompt) Context() CommandContext { + return GLOBAL +} + +func (Prompt) Aliases() []string { + return []string{"prompt"} +} + +func (*Prompt) CompleteCommand(arg string) []string { + return FilterList(ActiveCommandNames(), arg, nil) +} + +func (p Prompt) Execute(args []string) error { + cmd := opt.QuoteArgs(p.Cmd...) + app.RegisterPrompt(p.Text, cmd.String()) + return nil +} diff --git a/commands/pwd.go b/commands/pwd.go new file mode 100644 index 0000000..e60326e --- /dev/null +++ b/commands/pwd.go @@ -0,0 +1,35 @@ +package commands + +import ( + "os" + "time" + + "git.sr.ht/~rjarry/aerc/app" +) + +type PrintWorkDir struct{} + +func init() { + Register(PrintWorkDir{}) +} + +func (PrintWorkDir) Description() string { + return "Display aerc's current working directory." +} + +func (PrintWorkDir) Context() CommandContext { + return GLOBAL +} + +func (PrintWorkDir) Aliases() []string { + return []string{"pwd"} +} + +func (PrintWorkDir) Execute(args []string) error { + pwd, err := os.Getwd() + if err != nil { + return err + } + app.PushStatus(pwd, 10*time.Second) + return nil +} diff --git a/commands/quit.go b/commands/quit.go new file mode 100644 index 0000000..9c4fec5 --- /dev/null +++ b/commands/quit.go @@ -0,0 +1,40 @@ +package commands + +import ( + "fmt" + + "git.sr.ht/~rjarry/aerc/commands/mode" +) + +type Quit struct { + Force bool `opt:"-f" desc:"Force quit even if a task is pending."` +} + +func init() { + Register(Quit{}) +} + +func (Quit) Description() string { + return "Exit aerc." +} + +func (Quit) Context() CommandContext { + return GLOBAL +} + +func (Quit) Aliases() []string { + return []string{"quit", "q", "exit"} +} + +type ErrorExit int + +func (err ErrorExit) Error() string { + return "exit" +} + +func (q Quit) Execute(args []string) error { + if q.Force || mode.QuitAllowed() { + return ErrorExit(1) + } + return fmt.Errorf("A task is not done yet. Use -f to force an exit.") +} diff --git a/commands/redraw.go b/commands/redraw.go new file mode 100644 index 0000000..be25d5b --- /dev/null +++ b/commands/redraw.go @@ -0,0 +1,26 @@ +package commands + +import "git.sr.ht/~rjarry/aerc/lib/ui" + +type Redraw struct{} + +func init() { + Register(Redraw{}) +} + +func (Redraw) Description() string { + return "Force a full redraw of the screen." +} + +func (Redraw) Context() CommandContext { + return GLOBAL +} + +func (Redraw) Aliases() []string { + return []string{"redraw"} +} + +func (Redraw) Execute(args []string) error { + ui.QueueRefresh() + return nil +} diff --git a/commands/reload.go b/commands/reload.go new file mode 100644 index 0000000..0d4c489 --- /dev/null +++ b/commands/reload.go @@ -0,0 +1,194 @@ +package commands + +import ( + "os" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/ui" +) + +type Reload struct { + Binds bool `opt:"-B" desc:"Reload binds.conf."` + Conf bool `opt:"-C" desc:"Reload aerc.conf."` + Style string `opt:"-s" complete:"CompleteStyle" desc:"Reload the specified styleset."` +} + +func init() { + Register(Reload{}) +} + +func (Reload) Description() string { + return "Hot-reload configuration files." +} + +func (r *Reload) CompleteStyle(s string) []string { + var files []string + for _, dir := range config.Ui.StyleSetDirs { + entries, err := os.ReadDir(dir) + if err != nil { + log.Debugf("could not read directory '%s': %v", dir, + err) + continue + } + for _, e := range entries { + if e.IsDir() { + continue + } + files = append(files, e.Name()) + } + } + return FilterList(files, s, nil) +} + +func (Reload) Context() CommandContext { + return GLOBAL +} + +func (Reload) Aliases() []string { + return []string{"reload"} +} + +func (r Reload) Execute(args []string) error { + if !r.Binds && !r.Conf && r.Style == "" { + r.Binds = true + r.Conf = true + r.Style = config.Ui.StyleSetName + } + + reconfigure := false + + if r.Binds { + f, err := config.ReloadBinds() + if err != nil { + return err + } + app.PushSuccess("Binds reloaded: " + f) + } + + if r.Conf { + f, err := config.ReloadConf() + if err != nil { + return err + } + app.PushSuccess("Conf reloaded: " + f) + reconfigure = true + } + + if r.Style != "" { + config.Ui.ClearCache() + config.Ui.StyleSetName = r.Style + err := config.Ui.LoadStyle() + if err != nil { + return err + } + app.PushSuccess("Styleset: " + r.Style) + reconfigure = true + } + + if !reconfigure { + return nil + } + + // reload account views and message stores + for _, name := range app.AccountNames() { + + // rebuild account view + view, err := app.Account(name) + if err != nil { + continue + } + + dirlist := view.Directories() + if dirlist == nil { + continue + } + + wantTree := config.Ui.ForAccount(name).DirListTree + dirlist = adjustDirlist(dirlist, wantTree) + view.SetDirectories(dirlist) + + // now rebuild grid with correct dirlist + view.Configure() + + // reconfigure the message stores + for _, dir := range dirlist.List() { + store, ok := dirlist.MsgStore(dir) + if !ok { + continue + } + uiConf := dirlist.UiConfig(dir) + store.Configure(view.SortCriteria(uiConf)) + } + ui.Invalidate() + } + + // reload message viewers + doTabs(func(tab *ui.Tab) { + mv, ok := tab.Content.(*app.MessageViewer) + if !ok { + return + } + reloaded, err := app.NewMessageViewer( + mv.SelectedAccount(), + mv.MessageView(), + ) + if err != nil { + app.PushError(err.Error()) + return + } + app.ReplaceTab(mv, reloaded, tab.Name, false) + }) + + // reload composers + doTabs(func(tab *ui.Tab) { + c, ok := tab.Content.(*app.Composer) + if !ok { + return + } + _ = c.SwitchAccount(c.Account()) + }) + + return nil +} + +func adjustDirlist(d app.DirectoryLister, wantTree bool) app.DirectoryLister { + switch d := d.(type) { + case *app.DirectoryList: + if wantTree { + log.Tracef("dirlist: build tree") + tree := app.NewDirectoryTree(d) + tree.SelectedMsgStore() + return tree + } + log.Tracef("dirlist: already dirlist") + return d + case *app.DirectoryTree: + if !wantTree { + log.Tracef("dirtree: get dirlist") + return d.DirectoryList + } + log.Tracef("dirtree: already tree") + return d + default: + return d + } +} + +func doTabs(do func(*ui.Tab)) { + var tabname string + if t := app.SelectedTab(); t != nil { + tabname = t.Name + } + for i := range app.TabNames() { + tab := app.GetTab(i) + if tab == nil { + continue + } + do(tab) + } + if tabname != "" { + app.SelectTab(tabname) + } +} diff --git a/commands/send-keys.go b/commands/send-keys.go new file mode 100644 index 0000000..a49f4f1 --- /dev/null +++ b/commands/send-keys.go @@ -0,0 +1,57 @@ +package commands + +import ( + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rockorager/vaxis" + "github.com/pkg/errors" +) + +type SendKeys struct { + Keys string `opt:"..."` +} + +func init() { + Register(SendKeys{}) +} + +func (SendKeys) Description() string { + return "Send keystrokes to the currently visible terminal." +} + +func (SendKeys) Context() CommandContext { + return GLOBAL +} + +func (SendKeys) Aliases() []string { + return []string{"send-keys"} +} + +func (s SendKeys) Execute(args []string) error { + tab, ok := app.SelectedTabContent().(app.HasTerminal) + if !ok { + return errors.New("There is no terminal here") + } + + term := tab.Terminal() + if term == nil { + return errors.New("The terminal is not active") + } + + keys2send, err := config.ParseKeyStrokes(s.Keys) + if err != nil { + return errors.Wrapf(err, "Unable to parse keystroke: %q", s.Keys) + } + + for _, key := range keys2send { + ev := vaxis.Key{ + Keycode: key.Key, + Modifiers: key.Modifiers, + } + term.Event(ev) + } + + term.Invalidate() + + return nil +} diff --git a/commands/suspend.go b/commands/suspend.go new file mode 100644 index 0000000..dc2d6b5 --- /dev/null +++ b/commands/suspend.go @@ -0,0 +1,26 @@ +package commands + +import "git.sr.ht/~rjarry/aerc/lib/ui" + +type Suspend struct{} + +func init() { + Register(Suspend{}) +} + +func (Suspend) Description() string { + return "Suspend the aerc process." +} + +func (Suspend) Context() CommandContext { + return GLOBAL +} + +func (Suspend) Aliases() []string { + return []string{"suspend"} +} + +func (Suspend) Execute(args []string) error { + ui.QueueSuspend() + return nil +} diff --git a/commands/term.go b/commands/term.go new file mode 100644 index 0000000..e10f3e6 --- /dev/null +++ b/commands/term.go @@ -0,0 +1,80 @@ +package commands + +import ( + "os" + "os/exec" + + "github.com/riywo/loginshell" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/lib/ui" +) + +type Term struct { + Cmd []string `opt:"..." required:"false"` +} + +func init() { + Register(Term{}) +} + +func (Term) Description() string { + return "Open a new terminal tab." +} + +func (Term) Context() CommandContext { + return GLOBAL +} + +func (Term) Aliases() []string { + return []string{"terminal", "term"} +} + +func (t Term) Execute(args []string) error { + return TermCore(t.Cmd) +} + +// The help command is an alias for `term man` thus Term requires a simple func +func TermCore(args []string) error { + return TermCoreDirectory(args, "") +} + +func TermCoreDirectory(args []string, dir string) error { + if len(args) == 0 { + shell, err := loginshell.Shell() + if err != nil { + return err + } + args = []string{shell} + } + + if dir != "" { + if _, err := os.Stat(dir); os.IsNotExist(err) { + return err + } + } + + cmd := exec.Command(args[0], args[1:]...) + cmd.Dir = dir + term, err := app.NewTerminal(cmd) + if err != nil { + return err + } + tab := app.NewTab(term, args[0]) + term.OnTitle = func(title string) { + if title == "" { + title = args[0] + } + if tab.Name != title { + tab.Name = title + ui.Invalidate() + } + } + term.OnClose = func(err error) { + app.RemoveTab(term, false) + if err != nil { + app.PushError(err.Error()) + } + } + return nil +} diff --git a/commands/testdata/.hidden/foo.conf b/commands/testdata/.hidden/foo.conf new file mode 100644 index 0000000..190a180 --- /dev/null +++ b/commands/testdata/.hidden/foo.conf @@ -0,0 +1 @@ +123 diff --git a/commands/testdata/.keep-me b/commands/testdata/.keep-me new file mode 100644 index 0000000..b35a57e --- /dev/null +++ b/commands/testdata/.keep-me @@ -0,0 +1 @@ +This file is used by unit tests diff --git a/commands/testdata/Foobar b/commands/testdata/Foobar new file mode 100644 index 0000000..e69de29 diff --git a/commands/testdata/baz/unused.txt b/commands/testdata/baz/unused.txt new file mode 100644 index 0000000..573541a --- /dev/null +++ b/commands/testdata/baz/unused.txt @@ -0,0 +1 @@ +0 diff --git a/commands/testdata/foo.ini b/commands/testdata/foo.ini new file mode 100644 index 0000000..9d632e5 --- /dev/null +++ b/commands/testdata/foo.ini @@ -0,0 +1 @@ +# x diff --git a/commands/testdata/foo/bar.json b/commands/testdata/foo/bar.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/commands/testdata/foo/bar.json @@ -0,0 +1 @@ +{} diff --git a/commands/util.go b/commands/util.go new file mode 100644 index 0000000..fb1532f --- /dev/null +++ b/commands/util.go @@ -0,0 +1,297 @@ +package commands + +import ( + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/lithammer/fuzzysearch/fuzzy" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/lib" + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/xdg" + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/types" + "git.sr.ht/~rjarry/go-opt/v2" + "git.sr.ht/~rockorager/vaxis" +) + +// QuickTerm is an ephemeral terminal for running a single command and quitting. +func QuickTerm(args []string, stdin io.Reader, silent bool) (*app.Terminal, error) { + cmd := exec.Command(args[0], args[1:]...) + pipe, err := cmd.StdinPipe() + if err != nil { + return nil, err + } + + term, err := app.NewTerminal(cmd) + if err != nil { + return nil, err + } + + term.OnClose = func(err error) { + if err != nil { + app.PushError(err.Error()) + // remove the tab on error, otherwise it gets stuck + app.RemoveTab(term, false) + return + } + if silent { + app.RemoveTab(term, true) + } else { + app.PushStatus("Process complete, press any key to close.", + 10*time.Second) + term.OnEvent = func(event vaxis.Event) bool { + app.RemoveTab(term, true) + return true + } + } + } + + term.OnStart = func() { + status := make(chan error, 1) + + go func() { + defer log.PanicHandler() + + _, err := io.Copy(pipe, stdin) + defer pipe.Close() + status <- err + }() + + err := <-status + if err != nil { + app.PushError(err.Error()) + } + } + + return term, nil +} + +// CompletePath provides filesystem completions given a starting path. +func CompletePath(path string, onlyDirs bool) []string { + return completePath(path, onlyDirs, app.SelectedAccountUiConfig().FuzzyComplete) +} + +func completePath(path string, onlyDirs bool, fuzzyComplete bool) []string { + if path == ".." || strings.HasSuffix(path, "/..") { + return []string{path + "/"} + } + if path == "~" { + path = xdg.HomeDir() + "/" + } else if strings.HasPrefix(path, "~/") { + path = xdg.HomeDir() + strings.TrimPrefix(path, "~") + } + includeHidden := path == "." + if i := strings.LastIndex(path, "/"); i != -1 && i < len(path)-1 { + includeHidden = strings.HasPrefix(path[i+1:], ".") + } + + const currentDir = "." + dir, search := filepath.Split(path) + // for `file` case dir will be `` which does not work in listDir + if dir == "" { + dir = currentDir + } + + entries := listDir(dir, includeHidden) + filteredEntries := make([]string, 0, len(entries)) + for _, m := range entries { + testM := m + if dir != currentDir { + testM = dir + m + } + if isDir(testM) { + m += "/" + } else if onlyDirs { + continue + } + filteredEntries = append(filteredEntries, m) + } + + results := filterList( + filteredEntries, + search, + func(s string) string { + if dir != currentDir { + s = dir + s + } + return opt.QuoteArg(xdg.TildeHome(s)) + }, + fuzzyComplete, + ) + + sort.Strings(results) + + return results +} + +func isDir(path string) bool { + info, err := os.Stat(path) + if err != nil { + return false + } + + return info.IsDir() +} + +// return all filenames in a directory, optionally including hidden files +func listDir(path string, hidden bool) []string { + f, err := os.Open(path) + if err != nil { + return []string{} + } + + files, err := f.Readdirnames(-1) // read all dir names + if err != nil { + return []string{} + } + + if hidden { + return files + } + + var filtered []string + for _, g := range files { + if !strings.HasPrefix(g, ".") { + filtered = append(filtered, g) + } + } + + return filtered +} + +// MarkedOrSelected returns either all marked messages if any are marked or the +// selected message instead +func MarkedOrSelected(pm app.ProvidesMessages) ([]models.UID, error) { + // marked has priority over the selected message + marked, err := pm.MarkedMessages() + if err != nil { + return nil, err + } + if len(marked) > 0 { + marked = expandFoldedThreads(pm, marked) + return marked, nil + } + msg, err := pm.SelectedMessage() + if err != nil { + return nil, err + } + return expandFoldedThreads(pm, []models.UID{msg.Uid}), nil +} + +func expandFoldedThreads(pm app.ProvidesMessages, uids []models.UID) []models.UID { + store := pm.Store() + if store == nil { + return uids + } + expanded := make([]models.UID, len(uids)) + copy(expanded, uids) + for _, uid := range uids { + thread, err := store.Thread(uid) + if err != nil { + continue + } + if thread != nil && thread.FirstChild != nil && thread.FirstChild.Hidden > 0 { + _ = thread.Walk(func(t *types.Thread, _ int, __ error) error { + if t.Uid != uid { + expanded = append(expanded, t.Uid) + } + return nil + }) + } + + } + if len(uids) != len(expanded) { + log.Debugf("expand folded threads: %v -> %v\n", uids, expanded) + } + return expanded +} + +// UidsFromMessageInfos extracts a uid slice from a slice of MessageInfos +func UidsFromMessageInfos(msgs []*models.MessageInfo) []models.UID { + uids := make([]models.UID, len(msgs)) + i := 0 + for _, msg := range msgs { + uids[i] = msg.Uid + i++ + } + return uids +} + +func MsgInfoFromUids(store *lib.MessageStore, uids []models.UID, statusInfo func(string)) ([]*models.MessageInfo, error) { + infos := make([]*models.MessageInfo, len(uids)) + needHeaders := make([]models.UID, 0) + for i, uid := range uids { + var ok bool + infos[i], ok = store.Messages[uid] + if !ok { + return nil, fmt.Errorf("uid not found") + } + if infos[i] == nil { + needHeaders = append(needHeaders, uid) + } + } + if len(needHeaders) > 0 { + store.FetchHeaders(needHeaders, func(msg types.WorkerMessage) { + var info string + switch m := msg.(type) { + case *types.Done: + info = "All headers fetched. Please repeat command." + case *types.Error: + info = fmt.Sprintf("Encountered error while fetching headers: %v", m.Error) + } + if statusInfo != nil { + statusInfo(info) + } + }) + return nil, fmt.Errorf("Fetching missing message headers. Please wait.") + } + return infos, nil +} + +func QuoteSpace(s string) string { + return opt.QuoteArg(s) + " " +} + +// FilterList takes a list of valid completions and filters it, either +// by case smart prefix, or by fuzzy matching +// An optional post processing function can be passed to prepend, append or +// quote each value. +func FilterList( + valid []string, + search string, + postProc func(string) string, +) []string { + return filterList(valid, search, postProc, app.SelectedAccountUiConfig().FuzzyComplete) +} + +func filterList( + valid []string, + search string, + postProc func(string) string, + fuzzyComplete bool, +) []string { + if postProc == nil { + postProc = opt.QuoteArg + } + out := make([]string, 0, len(valid)) + if fuzzyComplete { + for _, v := range fuzzy.RankFindFold(search, valid) { + out = append(out, postProc(v.Target)) + } + } else { + for _, v := range valid { + if hasCaseSmartPrefix(v, search) { + out = append(out, postProc(v)) + } + } + } + return out +} diff --git a/commands/util_test.go b/commands/util_test.go new file mode 100644 index 0000000..90b2123 --- /dev/null +++ b/commands/util_test.go @@ -0,0 +1,88 @@ +package commands + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCompletePath(t *testing.T) { + os.Chdir("testdata") + defer os.Chdir("..") + + vectors := []struct { + arg string + onlyDirs bool + fuzzyComplete bool + expected []string + }{ + { + arg: "", + expected: []string{"Foobar", "baz/", "foo.ini", "foo/"}, + }, + { + arg: "", + onlyDirs: true, + expected: []string{"baz/", "foo/"}, + }, + { + arg: ".", + expected: []string{".hidden/", ".keep-me"}, + }, + { + arg: "fo", + expected: []string{"Foobar", "foo.ini", "foo/"}, + }, + { + arg: "Fo", + expected: []string{"Foobar"}, + }, + { + arg: "..", + expected: []string{"../"}, + }, + { + arg: "../..", + expected: []string{"../../"}, + }, + { + arg: "../testdata/", + expected: []string{ + "../testdata/Foobar", + "../testdata/baz/", + "../testdata/foo.ini", + "../testdata/foo/", + }, + }, + { + arg: "../testdata/f", + onlyDirs: true, + expected: []string{"../testdata/foo/"}, + }, + { + arg: "oo", + expected: []string{}, + }, + { + arg: "oo", + fuzzyComplete: true, + expected: []string{"Foobar", "foo.ini", "foo/"}, + }, + { + arg: "../testdata/oo", + expected: []string{}, + }, + { + arg: "../testdata/oo", + fuzzyComplete: true, + expected: []string{"../testdata/Foobar", "../testdata/foo.ini", "../testdata/foo/"}, + }, + } + for _, vec := range vectors { + t.Run(vec.arg, func(t *testing.T) { + res := completePath(vec.arg, vec.onlyDirs, vec.fuzzyComplete) + assert.Equal(t, vec.expected, res) + }) + } +} diff --git a/commands/z.go b/commands/z.go new file mode 100644 index 0000000..8ded252 --- /dev/null +++ b/commands/z.go @@ -0,0 +1,93 @@ +package commands + +import ( + "errors" + "os" + "os/exec" + "strings" + + "git.sr.ht/~rjarry/aerc/lib/xdg" +) + +type Zoxide struct { + Args []string `opt:"..." required:"false" metavar:"<query>..." complete:"CompleteFolder" desc:"Target folder."` +} + +func ZoxideAdd(arg string) error { + zargs := []string{"add", arg} + cmd := exec.Command("zoxide", zargs...) + err := cmd.Run() + return err +} + +func ZoxideQuery(args []string) (string, error) { + zargs := append([]string{"query"}, args...) + cmd := exec.Command("zoxide", zargs...) + res, err := cmd.Output() + return strings.TrimSuffix(string(res), "\n"), err +} + +func init() { + _, err := exec.LookPath("zoxide") + if err == nil { + Register(Zoxide{}) + } +} + +func (Zoxide) Description() string { + return "Change aerc's current working directory using zoxide." +} + +func (Zoxide) Context() CommandContext { + return GLOBAL +} + +func (Zoxide) Aliases() []string { + return []string{"z"} +} + +func (*Zoxide) CompleteFolder(arg string) []string { + return CompletePath(arg, true) +} + +// Execute calls zoxide add and query and delegates actually changing the +// directory to ChangeDirectory +func (z Zoxide) Execute(args []string) error { + if len(z.Args) == 0 { + z.Args = []string{"~"} + } + if len(z.Args) == 1 && (z.Args[0] == "~" || z.Args[0] == "-") { + if previousDir != "" { + err := ZoxideAdd(previousDir) + if err != nil { + return err + } + } + return ChangeDirectory{Target: z.Args[0]}.Execute(args) + } else { + target := xdg.ExpandHome(z.Args[0]) + _, err := os.Stat(target) + if err != nil || len(z.Args) > 1 { + // not a file, assume zoxide query + res, err := ZoxideQuery(z.Args) + if err != nil { + return errors.New("zoxide: no match found") + } else { + err := ZoxideAdd(res) + if err != nil { + return err + } + cd := ChangeDirectory{Target: res} + return cd.Execute([]string{"z", res}) + } + + } else { + err := ZoxideAdd(target) + if err != nil { + return err + } + return ChangeDirectory{Target: target}.Execute(args) + } + + } +} diff --git a/completer/completer.go b/completer/completer.go new file mode 100644 index 0000000..670db34 --- /dev/null +++ b/completer/completer.go @@ -0,0 +1,195 @@ +package completer + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "net/mail" + "os/exec" + "regexp" + "strings" + "syscall" + + "git.sr.ht/~rjarry/aerc/lib/format" + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/go-opt/v2" +) + +// A Completer is used to autocomplete text inputs based on the configured +// completion commands. +type Completer struct { + // AddressBookCmd is the command to run for completing email addresses. This + // command must output one completion on each line with fields separated by a + // tab character. The first field must be the address, and the second field, + // if present, the contact name. Only the email address field is required. + // The name field is optional. Additional fields are ignored. + AddressBookCmd string + + errHandler func(error) +} + +// A CompleteFunc accepts a string to be completed and returns a slice of +// completions candidates with a prefix to prepend to the chosen candidate +type CompleteFunc func(context.Context, string) ([]opt.Completion, string) + +// New creates a new Completer with the specified address book command. +func New(addressBookCmd string, errHandler func(error)) *Completer { + return &Completer{ + AddressBookCmd: addressBookCmd, + errHandler: errHandler, + } +} + +// ForHeader returns a CompleteFunc appropriate for the specified mail header. In +// the case of To, From, etc., the completer will get completions from the +// configured address book command. For other headers, a noop completer will be +// returned. If errors arise during completion, the errHandler will be called. +func (c *Completer) ForHeader(h string) CompleteFunc { + if isAddressHeader(h) { + if c.AddressBookCmd == "" { + return nil + } + // wrap completeAddress in an error handler + return func(ctx context.Context, s string) ([]opt.Completion, string) { + completions, prefix, err := c.completeAddress(ctx, s) + if err != nil { + c.handleErr(err) + return []opt.Completion{}, "" + } + return completions, prefix + } + } + return nil +} + +// isAddressHeader determines whether the address completer should be used for +// header h. +func isAddressHeader(h string) bool { + switch strings.ToLower(h) { + case "to", "from", "cc", "bcc": + return true + } + return false +} + +const maxCompletionLines = 100 + +var tooManyLines = fmt.Errorf("returned more than %d lines", maxCompletionLines) + +// completeAddress uses the configured address book completion command to fetch +// completions for the specified string, returning a slice of completions and +// a prefix to be prepended to the selected completion, or an error. +func (c *Completer) completeAddress(ctx context.Context, s string) ([]opt.Completion, string, error) { + prefix, candidate := c.parseAddress(s) + cmd, err := c.getAddressCmd(ctx, candidate) + if err != nil { + return nil, "", err + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, "", fmt.Errorf("stdout: %w", err) + } + stderr, err := cmd.StderrPipe() + if err != nil { + return nil, "", fmt.Errorf("stderr: %w", err) + } + // reset the process group id to allow killing all its children + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + if err := cmd.Start(); err != nil { + return nil, "", fmt.Errorf("cmd start: %w", err) + } + // Wait returns an error if the exit status != 0, which some completion + // programs will do to signal no matches. We don't want to spam the user with + // spurious error messages, so we'll ignore any errors that arise at this + // point. + defer cmd.Wait() //nolint:errcheck // see above + + completions, err := readCompletions(stdout) + if err != nil { + // make sure to kill the process *and* all its children + //nolint:errcheck // who cares? + syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + log.Warnf("command %s killed: %s", cmd, err) + } + if err != nil && !errors.Is(err, tooManyLines) { + buf, _ := io.ReadAll(stderr) + msg := strings.TrimSpace(string(buf)) + if msg != "" { + msg = ": " + msg + } + return nil, "", fmt.Errorf("read completions%s: %w", msg, err) + } + + return completions, prefix, nil +} + +// parseAddress will break an address header into a prefix (containing +// the already valid addresses) and an input for completion +func (c *Completer) parseAddress(s string) (string, string) { + pattern := regexp.MustCompile(`^(.*),\s+([^,]*)$`) + matches := pattern.FindStringSubmatch(s) + if matches == nil { + return "", s + } + return matches[1] + ", ", matches[2] +} + +// getAddressCmd constructs an exec.Cmd based on the configured command and +// specified query. +func (c *Completer) getAddressCmd(ctx context.Context, s string) (*exec.Cmd, error) { + if strings.TrimSpace(c.AddressBookCmd) == "" { + return nil, fmt.Errorf("no command configured") + } + queryCmd := strings.ReplaceAll(c.AddressBookCmd, "%s", s) + parts := opt.SplitArgs(queryCmd) + if len(parts) < 1 { + return nil, fmt.Errorf("empty command") + } + if len(parts) > 1 { + return exec.CommandContext(ctx, parts[0], parts[1:]...), nil + } + return exec.CommandContext(ctx, parts[0]), nil +} + +// readCompletions reads a slice of completions from r line by line. Each line +// must consist of tab-delimited fields. Only the first field (the email +// address field) is required, the second field (the contact name) is optional, +// and subsequent fields are ignored. +func readCompletions(r io.Reader) ([]opt.Completion, error) { + buf := bufio.NewReader(r) + var completions []opt.Completion + for i := 0; i < maxCompletionLines; i++ { + line, err := buf.ReadString('\n') + if errors.Is(err, io.EOF) { + return completions, nil + } else if err != nil { + return nil, err + } + if strings.TrimSpace(line) == "" { + // skip empty lines + continue + } + parts := strings.SplitN(line, "\t", 3) + addr, err := mail.ParseAddress(strings.TrimSpace(parts[0])) + if err != nil { + log.Warnf("line %d: %#v: could not parse address: %v", + line, err) + continue + } + if len(parts) > 1 { + addr.Name = strings.TrimSpace(parts[1]) + } + completions = append(completions, opt.Completion{ + Value: format.AddressForHumans(addr), + }) + } + return completions, tooManyLines +} + +func (c *Completer) handleErr(err error) { + if c.errHandler != nil { + c.errHandler(err) + } +} diff --git a/config/accounts.conf b/config/accounts.conf new file mode 100644 index 0000000..64621da --- /dev/null +++ b/config/accounts.conf @@ -0,0 +1,22 @@ +# +# aerc accounts configuration +# +# This file configures each of the available mail accounts. + +# You may add an arbitrary number of sections like so: +# +# [Personal] +# source=imaps://username[:password]@hostname[:port] +# outgoing=smtps+plain://username[:password]@hostname[:port] +# copy-to=Sent +# from=Joe Bloe <joe@example.org> +# +# [Work] +# source=imaps://username[:password]@hostname[:port] +# outgoing=/usr/bin/sendmail +# from=Jane Plain <jane@example.org> +# folders=INBOX,Sent,Archives +# default=Archives +# +# Each supported protocol may have some arbitrary number of extra configuration +# options. See aerc-[protocol](5) for details (i.e. aerc-imap). diff --git a/config/accounts.go b/config/accounts.go new file mode 100644 index 0000000..d52a016 --- /dev/null +++ b/config/accounts.go @@ -0,0 +1,376 @@ +package config + +import ( + "bytes" + "errors" + "fmt" + "net/url" + "os" + "os/exec" + "path" + "reflect" + "regexp" + "sort" + "strings" + "time" + + "git.sr.ht/~rjarry/aerc/lib/log" + "github.com/emersion/go-message/mail" + "github.com/go-ini/ini" +) + +var ( + EnablePinentry func() + DisablePinentry func() + SetPinentryEnv func(*exec.Cmd) +) + +type RemoteConfig struct { + Value string + PasswordCmd string + CacheCmd bool + cache string +} + +func (c *RemoteConfig) parseValue() (*url.URL, error) { + return url.Parse(c.Value) +} + +func (c *RemoteConfig) ConnectionString() (string, error) { + if c.Value == "" || c.PasswordCmd == "" { + return c.Value, nil + } + + u, err := c.parseValue() + if err != nil { + return "", err + } + + // ignore the command if a password is specified + if _, exists := u.User.Password(); exists { + return c.Value, nil + } + + // don't attempt to parse the command if the url is a path (ie /usr/bin/sendmail) + if !u.IsAbs() { + return c.Value, nil + } + + pw := c.cache + + if pw == "" { + usePinentry := EnablePinentry != nil && + DisablePinentry != nil && + SetPinentryEnv != nil + + cmd := exec.Command("sh", "-c", c.PasswordCmd) + cmd.Stdin = os.Stdin + + buf := new(bytes.Buffer) + cmd.Stderr = buf + + if usePinentry { + EnablePinentry() + defer DisablePinentry() + SetPinentryEnv(cmd) + } + + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("failed to read password: %v: %w", + buf.String(), err) + } + pw = strings.TrimSpace(string(output)) + } + u.User = url.UserPassword(u.User.Username(), pw) + if c.CacheCmd { + c.cache = pw + } + + return u.String(), nil +} + +type AccountConfig struct { + Name string + Backend string + // backend specific + Params map[string]string + + Archive string `ini:"archive" default:"Archive"` + CopyTo []string `ini:"copy-to" delim:","` + CopyToReplied bool `ini:"copy-to-replied" default:"false"` + StripBcc bool `ini:"strip-bcc" default:"true"` + Default string `ini:"default" default:"INBOX"` + Postpone string `ini:"postpone" default:"Drafts"` + From *mail.Address `ini:"from"` + UseEnvelopeFrom bool `ini:"use-envelope-from" default:"false"` + Aliases []*mail.Address `ini:"aliases"` + Source string `ini:"source" parse:"ParseSource"` + Folders []string `ini:"folders" delim:","` + FoldersExclude []string `ini:"folders-exclude" delim:","` + Headers []string `ini:"headers" delim:","` + HeadersExclude []string `ini:"headers-exclude" delim:","` + Outgoing RemoteConfig `ini:"outgoing" parse:"ParseOutgoing"` + SignatureFile string `ini:"signature-file"` + SignatureCmd string `ini:"signature-cmd"` + EnableFoldersSort bool `ini:"enable-folders-sort" default:"true"` + FoldersSort []string `ini:"folders-sort" delim:","` + AddressBookCmd string `ini:"address-book-cmd"` + SendAsUTC bool `ini:"send-as-utc" default:"false"` + SendWithHostname bool `ini:"send-with-hostname" default:"false"` + LocalizedRe *regexp.Regexp `ini:"subject-re-pattern" default:"(?i)^((AW|RE|SV|VS|ODP|R): ?)+"` + + // CheckMail + CheckMail time.Duration `ini:"check-mail"` + CheckMailCmd string `ini:"check-mail-cmd"` + CheckMailTimeout time.Duration `ini:"check-mail-timeout" default:"10s"` + CheckMailInclude []string `ini:"check-mail-include"` + CheckMailExclude []string `ini:"check-mail-exclude"` + + // PGP Config + PgpKeyId string `ini:"pgp-key-id"` + PgpAutoSign bool `ini:"pgp-auto-sign"` + PgpAttachKey bool `ini:"pgp-attach-key"` + PgpOpportunisticEncrypt bool `ini:"pgp-opportunistic-encrypt"` + PgpErrorLevel int `ini:"pgp-error-level" parse:"ParsePgpErrorLevel" default:"warn"` + PgpSelfEncrypt bool `ini:"pgp-self-encrypt"` + + // AuthRes + TrustedAuthRes []string `ini:"trusted-authres" delim:","` +} + +const ( + PgpErrorLevelNone = iota + PgpErrorLevelWarn + PgpErrorLevelError +) + +var Accounts []*AccountConfig + +func parseAccountsFromFile(root string, accts []string, filename string) error { + log.Debugf("Parsing accounts configuration from %s", filename) + + file, err := ini.LoadSources(ini.LoadOptions{ + KeyValueDelimiters: "=", + }, filename) + if err != nil { + return err + } + + starttls_warned := false + var globals *ini.Section + for _, _sec := range file.SectionStrings() { + if _sec == "DEFAULT" { + globals = file.Section(_sec) + continue + } + if len(accts) > 0 && !contains(accts, _sec) { + continue + } + sec := file.Section(_sec) + for key, val := range globals.KeysHash() { + if !sec.HasKey(key) { + _, _ = sec.NewKey(key, val) + } + } + + account, err := ParseAccountConfig(_sec, sec) + if err != nil { + log.Errorf("failed to load account [%s]: %s", _sec, err) + Warnings = append(Warnings, Warning{ + Title: "accounts.conf: error", + Body: fmt.Sprintf( + "Failed to load account [%s]:\n\n%s", + _sec, err, + ), + }) + continue + } + if _, ok := account.Params["smtp-starttls"]; ok && !starttls_warned { + Warnings = append(Warnings, Warning{ + Title: "accounts.conf: smtp-starttls is deprecated", + Body: ` +SMTP connections now use STARTTLS by default and the smtp-starttls setting is ignored. + +If you want to disable STARTTLS, append +insecure to the schema. +`, + }) + starttls_warned = true + } + + log.Debugf("accounts.conf: [%s] from = %s", account.Name, account.From) + Accounts = append(Accounts, account) + } + if len(accts) > 0 { + // Sort accounts struct to match the specified order, if we + // have one + var acctnames []string + for _, acc := range Accounts { + acctnames = append(acctnames, acc.Name) + } + var sortaccts []string + for _, acc := range accts { + if contains(acctnames, acc) { + sortaccts = append(sortaccts, acc) + } else { + log.Errorf("account [%s] not found", acc) + } + } + + idx := make(map[string]int) + for i, acct := range sortaccts { + idx[acct] = i + } + sort.Slice(Accounts, func(i, j int) bool { + return idx[Accounts[i].Name] < idx[Accounts[j].Name] + }) + } + + return nil +} + +func parseAccounts(root string, accts []string, filename string) error { + if filename == "" { + filename = path.Join(root, "accounts.conf") + err := checkConfigPerms(filename) + if errors.Is(err, os.ErrNotExist) { + // No config triggers account configuration wizard + return nil + } else if err != nil { + return err + } + } + + if err := parseAccountsFromFile(root, accts, filename); err != nil { + return fmt.Errorf("%s: %w", filename, err) + } + + return nil +} + +func ParseAccountConfig(name string, section *ini.Section) (*AccountConfig, error) { + account := AccountConfig{ + Name: name, + Params: make(map[string]string), + } + if err := MapToStruct(section, &account, true); err != nil { + return nil, err + } + for key, val := range section.KeysHash() { + backendSpecific := true + typ := reflect.TypeOf(account) + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + if field.Tag.Get("ini") == key { + backendSpecific = false + break + } + } + if backendSpecific { + account.Params[key] = val + } + } + if account.Source == "" { + return nil, fmt.Errorf("missing 'source' parameter") + } + + account.Backend = parseBackend(account.Source) + if account.From == nil { + return nil, fmt.Errorf("missing 'from' parameter") + } + if len(account.Headers) > 0 { + defaults := []string{ + "date", + "subject", + "from", + "sender", + "reply-to", + "to", + "cc", + "bcc", + "in-reply-to", + "message-id", + "references", + } + account.Headers = append(account.Headers, defaults...) + } + return &account, nil +} + +func parseBackend(source string) string { + u, err := url.Parse(source) + if err != nil { + return "" + } + if strings.HasPrefix(u.Scheme, "imap") { + return "imap" + } + if strings.HasPrefix(u.Scheme, "maildir") { + return "maildir" + } + if strings.HasPrefix(u.Scheme, "jmap") { + return "jmap" + } + return u.Scheme +} + +func (a *AccountConfig) ParseSource(sec *ini.Section, key *ini.Key) (string, error) { + var remote RemoteConfig + remote.Value = key.String() + if k, err := sec.GetKey("source-cred-cmd"); err == nil { + remote.PasswordCmd = k.String() + } + return remote.ConnectionString() +} + +func (a *AccountConfig) ParseOutgoing(sec *ini.Section, key *ini.Key) (RemoteConfig, error) { + var remote RemoteConfig + remote.Value = key.String() + if k, err := sec.GetKey("outgoing-cred-cmd"); err == nil { + remote.PasswordCmd = k.String() + } + if k, err := sec.GetKey("outgoing-cred-cmd-cache"); err == nil { + cache, err := k.Bool() + if err != nil { + return remote, err + } + remote.CacheCmd = cache + } + _, err := remote.parseValue() + return remote, err +} + +func (a *AccountConfig) ParsePgpErrorLevel(sec *ini.Section, key *ini.Key) (int, error) { + var level int + var err error + switch strings.ToLower(key.String()) { + case "none": + level = PgpErrorLevelNone + case "warn": + level = PgpErrorLevelWarn + case "error": + level = PgpErrorLevelError + default: + err = fmt.Errorf("unknown level: %s", key.String()) + } + return level, err +} + +// checkConfigPerms checks for too open permissions +// printing the fix on stdout and returning an error +func checkConfigPerms(filename string) error { + info, err := os.Stat(filename) + if err != nil { + return err + } + + perms := info.Mode().Perm() + if perms&0o44 != 0 && !General.UnsafeAccountsConf { + // group or others have read access + fmt.Fprintf(os.Stderr, "The file %v has too open permissions.\n", filename) + fmt.Fprintln(os.Stderr, "This is a security issue (it contains passwords).") + fmt.Fprintf(os.Stderr, "To fix it, run `chmod 600 %v`\n", filename) + return errors.New("account.conf permissions too lax") + } + return nil +} diff --git a/config/aerc.conf b/config/aerc.conf new file mode 100644 index 0000000..a11b8a5 --- /dev/null +++ b/config/aerc.conf @@ -0,0 +1,866 @@ +# +# aerc main configuration + +[general] +# +# Used as a default path for save operations if no other path is specified. +# ~ is expanded to the current user home dir. +# +#default-save-path= + +# If set to "gpg", aerc will use system gpg binary and keystore for all crypto +# operations. If set to "internal", the internal openpgp keyring will be used. +# If set to "auto", the system gpg will be preferred unless the internal +# keyring already exists, in which case the latter will be used. +# +# Default: auto +#pgp-provider=auto + +# By default, the file permissions of accounts.conf must be restrictive and +# only allow reading by the file owner (0600). Set this option to true to +# ignore this permission check. Use this with care as it may expose your +# credentials. +# +# Default: false +#unsafe-accounts-conf=false + +# Output log messages to specified file. A path starting with ~/ is expanded to +# the user home dir. When redirecting aerc's output to a file using > shell +# redirection, this setting is ignored and log messages are printed to stdout. +# +#log-file= + +# Only log messages above the specified level to log-file. Supported levels +# are: trace, debug, info, warn and error. When redirecting aerc's output to +# a file using > shell redirection, this setting is ignored and the log level +# is forced to trace. +# +# Default: info +#log-level=info + +# Disable IPC entirely. Don't run commands (including mailto:... and mbox:...) +# in an existing aerc instance, and don't start an IPC server to allow +# subsequent aerc instances to run commands in the current one. +# +# Default: false +#disable-ipc=false + +# Don't run mailto:... commands over IPC; start a new aerc instance with the +# composer instead. +# +# Default: false +#disable-ipc-mailto=false +# +# Don't run mbox:... commands over IPC; start a new aerc instance with the mbox +# file instead. +# +# Default: false +#disable-ipc-mbox=false + +# Set the $TERM environment variable used for the embedded terminal. +# +# Default: xterm-256color +#term=xterm-256color + +# Display OSC8 strings in the embedded terminal +# +# Default: false +#enable-osc8=false + +# Default shell command to use for :menu. This will be executed with sh -c and +# will run in an popover dialog. +# +# Any occurrence of %f will be replaced by a temporary file path where the +# command is expected to write output lines to be consumed by :menu. Otherwise, +# the lines will be read from the command's standard output. +# +# Examples: +# default-menu-cmd=fzf +# default-menu-cmd=fzf --multi +# default-menu-cmd=dmenu -l 20 +# default-menu-cmd=ranger --choosefiles=%f +# +#default-menu-cmd= + +[ui] +# +# Describes the format for each row in a mailbox view. This is a comma +# separated list of column names with an optional align and width suffix. After +# the column name, one of the '<' (left), ':' (center) or '>' (right) alignment +# characters can be added (by default, left) followed by an optional width +# specifier. The width is either an integer representing a fixed number of +# characters, or a percentage between 1% and 99% representing a fraction of the +# terminal width. It can also be one of the '*' (auto) or '=' (fit) special +# width specifiers. Auto width columns will be equally attributed the remaining +# terminal width. Fit width columns take the width of their contents. If no +# width specifier is set, '*' is used by default. +# +# Default: flags:4,name<20%,subject,date>= +#index-columns=flags:4,name<20%,subject,date>= + +# +# Each name in index-columns must have a corresponding column-$name setting. +# All column-$name settings accept golang text/template syntax. See +# aerc-templates(7) for available template attributes and functions. +# +# Here are some examples to show the To field instead of the From field for +# an email (modifying column-name): +# +# 1. a generic one +# column-name={{ .Peer | names | join ", " }} +# 2. based upon the selected folder +# column-name={{if match .Folder "^(Gesendet|Sent)$"}}{{index (.To | names) 0}}{{else}}{{index (.From | names) 0}}{{end}} +# +# Default settings +#column-flags={{.Flags | join ""}} +#column-name={{index (.From | names) 0}} +#column-subject={{.ThreadPrefix}}{{.Subject}} +#column-date={{.DateAutoFormat .Date.Local}} + +# +# String separator inserted between columns. When the column width specifier is +# an exact number of characters, the separator is added to it (i.e. the exact +# width will be fully available for the column contents). +# +# Default: " " +#column-separator=" " + +# +# See time.Time#Format at https://godoc.org/time#Time.Format +# +# Default: 2006 Jan 02 +#timestamp-format=2006 Jan 02 + +# +# Index-only time format for messages that were received/sent today. +# If this is empty, timestamp-format is used instead. +# +# Default: 15:04 +#this-day-time-format=15:04 + +# +# Index-only time format for messages that were received/sent within the last +# 7 days. If this is empty, timestamp-format is used instead. +# +# Default: Jan 02 +#this-week-time-format=Jan 02 + +# +# Index-only time format for messages that were received/sent this year. +# If this is empty, timestamp-format is used instead. +# +#Default: Jan 02 +#this-year-time-format=Jan 02 + +# +# Overrides timestamp-format for the message view. +# +# Default: 2006 Jan 02, 15:04 GMT-0700 +#message-view-timestamp-format=2006 Jan 02, 15:04 GMT-0700 + +# +# If set, overrides timestamp-format in the message view for messages +# that were received/sent today. +# +#message-view-this-day-time-format= + +# If set, overrides timestamp-format in the message view for messages +# that were received/sent within the last 7 days. +# +#message-view-this-week-time-format= + +# +# If set, overrides *timestamp-format* in the message view for messages +# that were received/sent this year. +# +#message-view-this-year-time-format= + +# +# Width of the sidebar, including the border. +# +# Default: 22 +#sidebar-width=22 + +# +# Default split layout for message list tabs. The syntax is: +# +# [<direction>] <size> +# +# <direction> is optional and defaults to horizontal. It can take one +# of the following values: h, horiz, horizontal, v, vert, vertical. +# +# <size> is a positive integer representing the size (in terminal cells) +# of the message list window. +# +#message-list-split= + +# +# Message to display when viewing an empty folder. +# +# Default: (no messages) +#empty-message=(no messages) + +# Message to display when no folders exists or are all filtered +# +# Default: (no folders) +#empty-dirlist=(no folders) +# +# Value to set {{.Subject}} template to when subject is empty. +# +# Default: (no subject) +#empty-subject=(no subject) + +# Enable mouse events in the ui, e.g. clicking and scrolling with the mousewheel +# +# Default: false +#mouse-enabled=false + +# +# Ring the bell when new messages are received +# +# Default: true +#new-message-bell=true + +# +# Template to use for Account tab titles +# +# Default: {{.Account}} +#tab-title-account={{.Account}} + +# +# Template to use for Composer tab titles +# +# Default: {{if .To}}to:{{index (.To | shortmboxes) 0}} {{end}}{{.SubjectBase}} +#tab-title-composer={{if .To}}to:{{index (.To | shortmboxes) 0}} {{end}}{{.SubjectBase}} + +# +# Template to use for Message Viewer tab titles +# +# Default: {{.Subject}} +#tab-title-viewer={{.Subject}} + + +# Marker to show before a pinned tab's name. +# +# Default: ` +#pinned-tab-marker='`' + +# Template for the left side of the directory list. +# See aerc-templates(7) for all available fields and functions. +# +# Default: {{.Folder}} +#dirlist-left={{.Folder}} + +# Template for the right side of the directory list. +# See aerc-templates(7) for all available fields and functions. +# +# Default: {{if .Unread}}{{humanReadable .Unread}}{{end}} +#dirlist-right={{if .Unread}}{{humanReadable .Unread}}{{end}} + +# Delay after which the messages are actually listed when entering a directory. +# This avoids loading messages when skipping over folders and makes the UI more +# responsive. If you do not want that, set it to 0s. +# +# Default: 200ms +#dirlist-delay=200ms + +# Display the directory list as a foldable tree that allows to collapse and +# expand the folders. +# +# Default: false +#dirlist-tree=false + +# If dirlist-tree is enabled, set level at which folders are collapsed by +# default. Set to 0 to disable. +# +# Default: 0 +#dirlist-collapse=0 + +# List of space-separated criteria to sort the messages by, see *sort* +# command in *aerc*(1) for reference. Prefixing a criterion with "-r " +# reverses that criterion. +# +# Example: "from -r date" +# +#sort= + +# Moves to next message when the current message is deleted +# +# Default: true +#next-message-on-delete=true + +# Automatically set the "seen" flag when a message is opened in the message +# viewer. +# +# Default: true +#auto-mark-read=true + +# The directories where the stylesets are stored. It takes a colon-separated +# list of directories. If this is unset or if a styleset cannot be found, the +# following paths will be used as a fallback in that order: +# +# ${XDG_CONFIG_HOME:-~/.config}/aerc/stylesets +# ${XDG_DATA_HOME:-~/.local/share}/aerc/stylesets +# /usr/local/share/aerc/stylesets +# /usr/share/aerc/stylesets +# +#stylesets-dirs= + +# Uncomment to use box-drawing characters for vertical and horizontal borders. +# +# Default: "│" and "─" +#border-char-vertical="│" +#border-char-horizontal="─" + +# Sets the styleset to use for the aerc ui elements. +# +# Default: default +#styleset-name=default + +# Activates fuzzy search in commands and their arguments: the typed string is +# searched in the command or option in any position, and need not be +# consecutive characters in the command or option. +# +# Default: false +#fuzzy-complete=false + +# How long to wait after the last input before auto-completion is triggered. +# +# Default: 250ms +#completion-delay=250ms + +# The minimum required characters to allow auto-completion to be triggered after +# completion-delay. +# +# Setting this to "manual" disables automatic completion, leaving only the +# manually triggered completion with the $complete key (see aerc-binds(5) for +# more details). +# +# Default: 1 +#completion-min-chars=1 + +# +# Global switch for completion popovers +# +# Default: true +#completion-popovers=true + +# Uncomment to use UTF-8 symbols to indicate PGP status of messages +# +# Default: ASCII +#icon-unencrypted= +#icon-encrypted=✔ +#icon-signed=✔ +#icon-signed-encrypted=✔ +#icon-unknown=✘ +#icon-invalid=⚠ + +# Reverses the order of the message list. By default, the message list is +# ordered with the newest (highest UID) message on top. Reversing the order +# will put the oldest (lowest UID) message on top. This can be useful in cases +# where the backend does not support sorting. +# +# Default: false +#reverse-msglist-order = false + +# Reverse display of the message threads. Default order is the initial +# message is on the top with all the replies being displayed below. The +# reverse option will put the initial message at the bottom with the +# replies on top. +# +# Default: false +#reverse-thread-order=false + +# Positions the cursor on the last message in the message list (at the +# bottom of the view) when opening a new folder. +# +# Default: false +#select-last-message=false + +# Sort the thread siblings according to the sort criteria for the messages. If +# sort-thread-siblings is false, the thread siblings will be sorted based on +# the message UID in ascending order. This option is only applicable for +# client-side threading with a backend that enables sorting. Note that there's +# a performance impact when sorting is activated. +# +# Default: false +#sort-thread-siblings=false + +# Set the scroll offset in number of lines from the top and bottom of the +# message list. +# +# Default: 0 +#msglist-scroll-offset = 0 + +# +# Enable a threaded view of messages. If this is not supported by the backend +# (IMAP server or notmuch), threads will be built by the client. +# +# Default: false +#threading-enabled=false + +# Force client-side thread building +# +# Default: false +#force-client-threads=false + +# If no References nor In-Reply-To headers can be matched to build client side +# threads, fallback to similar subjects. +# +# Default: false +#threading-by-subject=false + +# Show thread context enables messages which do not match the current query (or +# belong to the current mailbox) to be shown for context. These messages can be +# styled separately using "msglist_thread_context" in a styleset. This feature +# is not supported by all backends +# +# Default: false +#show-thread-context=false + +# Debounce client-side thread building +# +# Default: 50ms +#client-threads-delay=50ms + +# +# Thread prefix customization: + +# +# Customize the thread prefix appearance by selecting the arrow head. +# +# Default: ">" +#thread-prefix-tip = ">" + +# +# Customize the thread prefix appearance by selecting the arrow indentation. +# +# Default: " " +#thread-prefix-indent = " " + +# +# Customize the thread prefix appearance by selecting the vertical extension of +# the arrow. +# +# Default: "│" +#thread-prefix-stem = "│" + +# +# Customize the thread prefix appearance by selecting the horizontal extension +# of the arrow. +# +# Default: "" +#thread-prefix-limb = "" + +# +# Customize the thread prefix appearance by selecting the folded thread +# indicator. +# +# Default: "+" +#thread-prefix-folded = "+" + +# +# Customize the thread prefix appearance by selecting the unfolded thread +# indicator. +# +# Default: "" +#thread-prefix-unfolded = "" + +# +# Customize the thread prefix appearance by selecting the first child connector. +# +# Default: "" +#thread-prefix-first-child = "" + +# +# Customize the thread prefix appearance by selecting the connector used if +# the message has siblings. +# +# Default: "├─" +#thread-prefix-has-siblings = "├─" + +# +# Customize the thread prefix appearance by selecting the connector used if the +# message has no parents and no children. +# +# Default: "" +#thread-prefix-lone = "" + +# +# Customize the thread prefix appearance by selecting the connector used if the +# message has no parents and has children. +# +# Default: "" +#thread-prefix-orphan = "" + +# +# Customize the thread prefix appearance by selecting the connector for the last +# sibling. +# +# Default: "└─" +#thread-prefix-last-sibling = "└─" + +# +# Customize the reversed thread prefix appearance by selecting the connector for +# the last sibling. +# +# Default: "┌─" +#thread-prefix-last-sibling-reverse = "┌─" + +# +# Customize the thread prefix appearance by selecting the connector for dummy +# thread. +# +# Default: "┬─" +#thread-prefix-dummy = "┬─" + +# +# Customize the reversed thread prefix appearance by selecting the connector for +# dummy thread. +# +# Default: "┴─" +#thread-prefix-dummy-reverse = "┴─" + +# +# Customize the reversed thread prefix appearance by selecting the first child +# connector. +# +# Default: "" +#thread-prefix-first-child-reverse = "" + +# +# Customize the reversed thread prefix appearance by selecting the connector +# used if the message has no parents and has children. +# +# Default: "" +#thread-prefix-orphan-reverse = "" + +[statusline] +# +# Describes the format for the status line. This is a comma separated list of +# column names with an optional align and width suffix. See [ui].index-columns +# for more details. To completely mute the status line except for push +# notifications, explicitly set status-columns to an empty string. +# +# Default: left<*,center:=,right>* +#status-columns=left<*,center:=,right>* + +# +# Each name in status-columns must have a corresponding column-$name setting. +# All column-$name settings accept golang text/template syntax. See +# aerc-templates(7) for available template attributes and functions. +# +# Default settings +#column-left=[{{.Account}}] {{.StatusInfo}} +#column-center={{.PendingKeys}} +#column-right={{.TrayInfo}} | {{cwd}} + +# +# String separator inserted between columns. +# See [ui].column-separator for more details. +# +#column-separator=" " + +# Specifies the separator between grouped statusline elements. +# +# Default: " | " +#separator=" | " + +# Defines the mode for displaying the status elements. +# Options: text, icon +# +# Default: text +#display-mode=text + +[viewer] +# +# Specifies the pager to use when displaying emails. Note that some filters +# may add ANSI codes to add color to rendered emails, so you may want to use a +# pager which supports ANSI codes. +# +# Default: less -Rc +#pager=less -Rc + +# +# If an email offers several versions (multipart), you can configure which +# mimetype to prefer. For example, this can be used to prefer plaintext over +# html emails. +# +# Default: text/plain,text/html +#alternatives=text/plain,text/html + +# +# Default setting to determine whether to show full headers or only parsed +# ones in message viewer. +# +# Default: false +#show-headers=false + +# +# Layout of headers when viewing a message. To display multiple headers in the +# same row, separate them with a pipe, e.g. "From|To". Rows will be hidden if +# none of their specified headers are present in the message. +# +# Default: From|To,Cc|Bcc,Date,Subject +#header-layout=From|To,Cc|Bcc,Date,Subject + +# Whether to always show the mimetype of an email, even when it is just a single part +# +# Default: false +#always-show-mime=false + +# Define the maximum height of the mimetype switcher before a scrollbar is used. +# The height of the mimetype switcher is restricted to half of the display +# height. If the provided value for the height is zero, the number of parts will +# be used as the height of the type switcher. +# +# Default: 0 +#max-mime-height = 0 + +# Parses and extracts http links when viewing a message. Links can then be +# accessed with the open-link command. +# +# Default: true +#parse-http-links=true + +[compose] +# +# Specifies the command to run the editor with. It will be shown in an embedded +# terminal, though it may also launch a graphical window if the environment +# supports it. Defaults to $EDITOR, or vi. +#editor= + +# +# When set, aerc will create and read .eml files for composing that have +# non-standard \n linebreaks. This is only relevant if the used editor does not +# support CRLF linebreaks. +# +#lf-editor=false + +# +# Default header fields to display when composing a message. To display +# multiple headers in the same row, separate them with a pipe, e.g. "To|From". +# +# Default: To|From,Subject +#header-layout=To|From,Subject + +# +# Edit headers into the text editor instead than separate fields. +# +# When this is true, address-book-cmd is not supported and address completion +# is left to the editor itself. Also, displaying multiple headers on the same +# line is not possible. +# +# Default: false +#edit-headers=false + +# +# Sets focus to the email body when the composer window opens. +# +# Default: false +#focus-body=false + +# +# Specifies the command to be used to tab-complete email addresses. Any +# occurrence of "%s" in the address-book-cmd will be replaced with what the +# user has typed so far. +# +# The command must output the completions to standard output, one completion +# per line. Each line must be tab-delimited, with an email address occurring as +# the first field. Only the email address field is required. The second field, +# if present, will be treated as the contact name. Additional fields are +# ignored. +# +# This parameter can also be set per account in accounts.conf. +#address-book-cmd= + +# Specifies the command to be used to select attachments. Any occurrence of +# '%s' in the file-picker-cmd will be replaced with the argument <arg> +# to :attach -m <arg>. Any occurrence of '%f' will be replaced by the +# location of a temporary file, from which aerc will read the selected files. +# +# If '%f' is not present, the command must output the selected files to +# standard output, one file per line. If it is present, then aerc does not +# capture the standard output and instead reads the files from the temporary +# file which should have the same format. +#file-picker-cmd= + +# +# Allow to address yourself when replying +# +# Default: true +#reply-to-self=true + +# Warn before sending an email with an empty subject. +# +# Default: false +#empty-subject-warning=false + +# +# Warn before sending an email that matches the specified regexp but does not +# have any attachments. Leave empty to disable this feature. +# +# Uses Go's regexp syntax, documented at https://golang.org/s/re2syntax. The +# "(?im)" flags are set by default (case-insensitive and multi-line). +# +# Example: +# no-attachment-warning=^[^>]*attach(ed|ment) +# +#no-attachment-warning= + +# +# When set, aerc will generate "format=flowed" bodies with a content type of +# "text/plain; format=flowed" as described in RFC3676. This format is easier to +# handle for some mailing software, and generally just looks like ordinary +# text. To actually make use of this format's features, you'll need support in +# your editor. +# +#format-flowed=false + +[multipart-converters] +# +# Converters allow to generate multipart/alternative messages by converting the +# main text/plain part into any other MIME type. Only exact MIME types are +# accepted. The commands are invoked with sh -c and are expected to output +# valid UTF-8 text. +# +# Example (obviously, this requires that you write your main text/plain body +# using the markdown syntax): +#text/html=pandoc -f markdown -t html --standalone + +[filters] +# +# Filters allow you to pipe an email body through a shell command to render +# certain emails differently, e.g. highlighting them with ANSI escape codes. +# +# The commands are invoked with sh -c. The following folders are prepended to +# the system $PATH to allow referencing filters from their name only: +# +# ${XDG_CONFIG_HOME:-~/.config}/aerc/filters +# ~/.local/libexec/aerc/filters +# ${XDG_DATA_HOME:-~/.local/share}/aerc/filters +# $PREFIX/libexec/aerc/filters +# $PREFIX/share/aerc/filters +# /usr/libexec/aerc/filters +# /usr/share/aerc/filters +# +# If you want to run a program in your default $PATH which has the same name +# as a builtin filter (e.g. /usr/bin/colorize), use its absolute path. +# +# The following variables are defined in the filter command environment: +# +# AERC_MIME_TYPE the part MIME type/subtype +# AERC_FORMAT the part content type format= parameter +# AERC_FILENAME the attachment filename (if any) +# AERC_SUBJECT the message Subject header value +# AERC_FROM the message From header value +# +# The first filter which matches the email's mimetype will be used, so order +# them from most to least specific. +# +# You can also match on non-mimetypes, by prefixing with the header to match +# against (non-case-sensitive) and a comma, e.g. subject,text will match a +# subject which contains "text". Use header,~regex to match against a regex. +# +text/plain=colorize +text/calendar=calendar +message/delivery-status=colorize +message/rfc822=colorize +#text/html=pandoc -f html -t plain | colorize +text/html=! html +#text/html=! w3m -T text/html -I UTF-8 +#text/*=bat -fP --file-name="$AERC_FILENAME" +#application/x-sh=bat -fP -l sh +#image/*=catimg -w $(tput cols) - +#subject,~Git(hub|lab)=lolcat -f +#from,thatguywhodoesnothardwraphismessages=wrap -w 100 | colorize + +# This special filter is only used to post-process email headers when +# [viewer].show-headers=true +# By default, headers are piped directly into the pager. +# +.headers=colorize + +[openers] +# +# Openers allow you to specify the command to use for the :open and :open-link +# actions on a per-MIME-type basis. The :open-link URL scheme is used to +# determine the MIME type as follows: x-scheme-handler/<scheme>. +# +# {} is expanded as the temporary filename or URL to be opened with proper +# shell quoting. If it is not encountered in the command, the filename/URL will +# be appended to the end of the command. The command will then be executed with +# `sh -c`. +# +# Like [filters], openers support basic shell globbing. The first opener which +# matches the part's MIME type (or URL scheme handler MIME type) will be used, +# so order them from most to least specific. +# +# Examples: +# x-scheme-handler/irc=hexchat +# x-scheme-handler/http*=printf '%s' {} | wl-copy +# text/html=surf -dfgms +# text/plain=gvim {} +125 +# message/rfc822=thunderbird + +[hooks] +# +# Hooks are triggered whenever the associated event occurs. + +# +# Executed when a new email arrives in the selected folder +#mail-received=notify-send "[$AERC_ACCOUNT/$AERC_FOLDER] New mail from $AERC_FROM_NAME" "$AERC_SUBJECT" + +# +# Executed when mail is deleted from a folder +#mail-deleted=mbsync "$AERC_ACCOUNT:$AERC_FOLDER" & + +# +# Executed when aerc adds mail to a folder +#mail-added=mbsync "$AERC_ACCOUNT:$AERC_FOLDER" & + +# +# Executed when aerc starts +#aerc-startup=aerc :terminal calcurse && aerc :next-tab + +# +# Executed when aerc shuts down. +#aerc-shutdown= + +# +# Executed when notmuch tags are modified. +#tag-modified= + +# +# Executed when flags are changed on a message. +#flag-changed=mbsync "$AERC_ACCOUNT:$AERC_FOLDER" & + +[templates] +# Templates are used to populate email bodies automatically. +# + +# The directories where the templates are stored. It takes a colon-separated +# list of directories. If this is unset or if a template cannot be found, the +# following paths will be used as a fallback in that order: +# +# ${XDG_CONFIG_HOME:-~/.config}/aerc/templates +# ${XDG_DATA_HOME:-~/.local/share}/aerc/templates +# /usr/local/share/aerc/templates +# /usr/share/aerc/templates +# +#template-dirs= + +# The default template to be used for new messages. +# +# default: new_message +#new-message=new_message + +# The default template to be used for quoted replies. +# +# default: quoted_reply +#quoted-reply=quoted_reply + +# The default template to be used for forward as body. +# +# default: forward_as_body +#forwards=forward_as_body diff --git a/config/binds.conf b/config/binds.conf new file mode 100644 index 0000000..9546123 --- /dev/null +++ b/config/binds.conf @@ -0,0 +1,186 @@ +# Binds are of the form <key sequence> = <command to run> +# To use '=' in a key sequence, substitute it with "Eq": "<Ctrl+Eq>" +# If you wish to bind #, you can wrap the key sequence in quotes: "#" = quit +<C-p> = :prev-tab<Enter> +<C-PgUp> = :prev-tab<Enter> +<C-n> = :next-tab<Enter> +<C-PgDn> = :next-tab<Enter> +\[t = :prev-tab<Enter> +\]t = :next-tab<Enter> +<C-t> = :term<Enter> +? = :help keys<Enter> +<C-c> = :prompt 'Quit?' quit<Enter> +<C-q> = :prompt 'Quit?' quit<Enter> +<C-z> = :suspend<Enter> + +[messages] +q = :prompt 'Quit?' quit<Enter> + +j = :next<Enter> +<Down> = :next<Enter> +<C-d> = :next 50%<Enter> +<C-f> = :next 100%<Enter> +<PgDn> = :next 100%<Enter> + +k = :prev<Enter> +<Up> = :prev<Enter> +<C-u> = :prev 50%<Enter> +<C-b> = :prev 100%<Enter> +<PgUp> = :prev 100%<Enter> +g = :select 0<Enter> +G = :select -1<Enter> + +J = :next-folder<Enter> +<C-Down> = :next-folder<Enter> +K = :prev-folder<Enter> +<C-Up> = :prev-folder<Enter> +H = :collapse-folder<Enter> +<C-Left> = :collapse-folder<Enter> +L = :expand-folder<Enter> +<C-Right> = :expand-folder<Enter> + +v = :mark -t<Enter> +<Space> = :mark -t<Enter>:next<Enter> +V = :mark -v<Enter> + +T = :toggle-threads<Enter> +zc = :fold<Enter> +zo = :unfold<Enter> +za = :fold -t<Enter> +zM = :fold -a<Enter> +zR = :unfold -a<Enter> +<tab> = :fold -t<Enter> + +zz = :align center<Enter> +zt = :align top<Enter> +zb = :align bottom<Enter> + +<Enter> = :view<Enter> +d = :choose -o y 'Really delete this message' delete-message<Enter> +D = :delete<Enter> +a = :archive flat<Enter> +A = :unmark -a<Enter>:mark -T<Enter>:archive flat<Enter> + +C = :compose<Enter> +m = :compose<Enter> + +b = :bounce<space> + +rr = :reply -a<Enter> +rq = :reply -aq<Enter> +Rr = :reply<Enter> +Rq = :reply -q<Enter> + +c = :cf<space> +$ = :term<space> +! = :term<space> +| = :pipe<space> + +/ = :search<space> +\ = :filter<space> +n = :next-result<Enter> +N = :prev-result<Enter> +<Esc> = :clear<Enter> + +s = :split<Enter> +S = :vsplit<Enter> + +pl = :patch list<Enter> +pa = :patch apply <Tab> +pd = :patch drop <Tab> +pb = :patch rebase<Enter> +pt = :patch term<Enter> +ps = :patch switch <Tab> + +[messages:folder=Drafts] +<Enter> = :recall<Enter> + +[view] +/ = :toggle-key-passthrough<Enter>/ +q = :close<Enter> +O = :open<Enter> +o = :open<Enter> +S = :save<space> +| = :pipe<space> +D = :delete<Enter> +A = :archive flat<Enter> + +<C-l> = :open-link <space> + +f = :forward<Enter> +rr = :reply -a<Enter> +rq = :reply -aq<Enter> +Rr = :reply<Enter> +Rq = :reply -q<Enter> + +H = :toggle-headers<Enter> +<C-k> = :prev-part<Enter> +<C-Up> = :prev-part<Enter> +<C-j> = :next-part<Enter> +<C-Down> = :next-part<Enter> +J = :next<Enter> +<C-Right> = :next<Enter> +K = :prev<Enter> +<C-Left> = :prev<Enter> + +[view::passthrough] +$noinherit = true +$ex = <C-x> +<Esc> = :toggle-key-passthrough<Enter> + +[compose] +# Keybindings used when the embedded terminal is not selected in the compose +# view +$noinherit = true +$ex = <C-x> +$complete = <C-o> +<C-k> = :prev-field<Enter> +<C-Up> = :prev-field<Enter> +<C-j> = :next-field<Enter> +<C-Down> = :next-field<Enter> +<A-p> = :switch-account -p<Enter> +<C-Left> = :switch-account -p<Enter> +<A-n> = :switch-account -n<Enter> +<C-Right> = :switch-account -n<Enter> +<tab> = :next-field<Enter> +<backtab> = :prev-field<Enter> +<C-p> = :prev-tab<Enter> +<C-PgUp> = :prev-tab<Enter> +<C-n> = :next-tab<Enter> +<C-PgDn> = :next-tab<Enter> + +[compose::editor] +# Keybindings used when the embedded terminal is selected in the compose view +$noinherit = true +$ex = <C-x> +<C-k> = :prev-field<Enter> +<C-Up> = :prev-field<Enter> +<C-j> = :next-field<Enter> +<C-Down> = :next-field<Enter> +<C-p> = :prev-tab<Enter> +<C-PgUp> = :prev-tab<Enter> +<C-n> = :next-tab<Enter> +<C-PgDn> = :next-tab<Enter> + +[compose::review] +# Keybindings used when reviewing a message to be sent +# Inline comments are used as descriptions on the review screen +y = :send<Enter> # Send +n = :abort<Enter> # Abort (discard message, no confirmation) +s = :sign<Enter> # Toggle signing of this message with your PGP key +x = :encrypt<Enter> # Toggle encryption of this message to all recipients +v = :preview<Enter> # Preview message +p = :postpone<Enter> # Postpone +q = :choose -o d discard abort -o p postpone postpone<Enter> # Abort or postpone +e = :edit<Enter> # Edit (body and headers) +a = :attach<space> # Add attachment +d = :detach<space> # Remove attachment + +[terminal] +$noinherit = true +$ex = <C-x> + +<C-p> = :prev-tab<Enter> +<C-n> = :next-tab<Enter> +<C-PgUp> = :prev-tab<Enter> +<C-PgDn> = :next-tab<Enter> diff --git a/config/binds.go b/config/binds.go new file mode 100644 index 0000000..2fc4fe1 --- /dev/null +++ b/config/binds.go @@ -0,0 +1,719 @@ +package config + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "path" + "regexp" + "strings" + "unicode" + "unicode/utf8" + + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rockorager/vaxis" + "github.com/go-ini/ini" +) + +type BindingConfig struct { + Global *KeyBindings + AccountWizard *KeyBindings + Compose *KeyBindings + ComposeEditor *KeyBindings + ComposeReview *KeyBindings + MessageList *KeyBindings + MessageView *KeyBindings + MessageViewPassthrough *KeyBindings + Terminal *KeyBindings +} + +type bindsContextType int + +const ( + bindsContextFolder bindsContextType = iota + bindsContextAccount +) + +type BindingConfigContext struct { + ContextType bindsContextType + Regex *regexp.Regexp + Bindings *KeyBindings +} + +type KeyStroke struct { + Modifiers vaxis.ModifierMask + Key rune +} + +type Binding struct { + Output []KeyStroke + Input []KeyStroke + + Annotation string +} + +type KeyBindings struct { + Bindings []*Binding + // If false, disable global keybindings in this context + Globals bool + // Which key opens the ex line (default is :) + ExKey KeyStroke + // Which key triggers completion (default is <tab>) + CompleteKey KeyStroke + + // private + contextualBinds []*BindingConfigContext + contextualCounts map[bindsContextType]int + contextualCache map[bindsContextKey]*KeyBindings +} + +type bindsContextKey struct { + ctxType bindsContextType + value string +} + +const ( + BINDING_FOUND = iota + BINDING_INCOMPLETE + BINDING_NOT_FOUND +) + +type BindingSearchResult int + +func defaultBindsConfig() *BindingConfig { + // These bindings are not configurable + wizard := NewKeyBindings() + wizard.ExKey = KeyStroke{Key: 'e', Modifiers: vaxis.ModCtrl} + wizard.Globals = false + quit, _ := ParseBinding("<C-q>", ":quit<Enter>", "Quit aerc") + wizard.Add(quit) + return &BindingConfig{ + Global: NewKeyBindings(), + AccountWizard: wizard, + Compose: NewKeyBindings(), + ComposeEditor: NewKeyBindings(), + ComposeReview: NewKeyBindings(), + MessageList: NewKeyBindings(), + MessageView: NewKeyBindings(), + MessageViewPassthrough: NewKeyBindings(), + Terminal: NewKeyBindings(), + } +} + +var Binds = defaultBindsConfig() + +func parseBindsFromFile(root string, filename string) error { + log.Debugf("Parsing key bindings configuration from %s", filename) + binds, err := ini.LoadSources(ini.LoadOptions{ + KeyValueDelimiters: "=", + // IgnoreInlineComment is set to true which tells ini's parser + // to treat comments (#) on the same line as part of the value; + // hence we need cut the comment off ourselves later + IgnoreInlineComment: true, + }, filename) + if err != nil { + return err + } + + baseGroups := map[string]**KeyBindings{ + "default": &Binds.Global, + "compose": &Binds.Compose, + "messages": &Binds.MessageList, + "terminal": &Binds.Terminal, + "view": &Binds.MessageView, + "view::passthrough": &Binds.MessageViewPassthrough, + "compose::editor": &Binds.ComposeEditor, + "compose::review": &Binds.ComposeReview, + } + + // Base Bindings + for _, sectionName := range binds.SectionStrings() { + // Handle :: delimiter + baseSectionName := strings.ReplaceAll(sectionName, "::", "////") + sections := strings.Split(baseSectionName, ":") + baseOnly := len(sections) == 1 + baseSectionName = strings.ReplaceAll(sections[0], "////", "::") + + group, ok := baseGroups[strings.ToLower(baseSectionName)] + if !ok { + return errors.New("Unknown keybinding group " + sectionName) + } + + if baseOnly { + err = LoadBinds(binds, baseSectionName, group) + if err != nil { + return err + } + } + } + + log.Debugf("binds.conf: %#v", Binds) + return nil +} + +func parseBinds(root string, filename string) error { + if filename == "" { + filename = path.Join(root, "binds.conf") + if _, err := os.Stat(filename); errors.Is(err, os.ErrNotExist) { + fmt.Printf("%s not found, installing the system default\n", filename) + if err := installTemplate(root, "binds.conf"); err != nil { + return err + } + } + } + SetBindsFilename(filename) + if err := parseBindsFromFile(root, filename); err != nil { + return fmt.Errorf("%s: %w", filename, err) + } + + return nil +} + +func LoadBindingSection(sec *ini.Section) (*KeyBindings, error) { + bindings := NewKeyBindings() + for _, k := range sec.Keys() { + key := k.Name() + value, annotation, _ := strings.Cut(k.String(), " # ") + value = strings.TrimSpace(value) + switch key { + case "$ex": + strokes, err := ParseKeyStrokes(value) + if err != nil { + return nil, err + } + if len(strokes) != 1 { + return nil, errors.New("Invalid binding") + } + bindings.ExKey = strokes[0] + case "$noinherit": + if value == "false" { + continue + } + if value != "true" { + return nil, errors.New("Invalid binding") + } + bindings.Globals = false + case "$complete": + strokes, err := ParseKeyStrokes(value) + if err != nil { + return nil, err + } + if len(strokes) != 1 { + return nil, errors.New("Invalid binding") + } + bindings.CompleteKey = strokes[0] + default: + annotation = strings.TrimSpace(annotation) + binding, err := ParseBinding(key, value, annotation) + if err != nil { + return nil, err + } + bindings.Add(binding) + } + } + return bindings, nil +} + +func LoadBinds(binds *ini.File, baseName string, baseGroup **KeyBindings) error { + if sec, err := binds.GetSection(baseName); err == nil { + binds, err := LoadBindingSection(sec) + if err != nil { + return err + } + *baseGroup = MergeBindings(binds, *baseGroup) + } + + b := *baseGroup + + if baseName == "default" { + b.Globals = false + } + + for _, sectionName := range binds.SectionStrings() { + if !strings.HasPrefix(sectionName, baseName+":") || + strings.HasPrefix(sectionName, baseName+"::") { + continue + } + + bindSection, err := binds.GetSection(sectionName) + if err != nil { + return err + } + + binds, err := LoadBindingSection(bindSection) + if err != nil { + return err + } + if baseName == "default" { + binds.Globals = false + } + + contextualBind := BindingConfigContext{ + Bindings: binds, + } + + var index int + if strings.Contains(sectionName, "=") { + index = strings.Index(sectionName, "=") + value := string(sectionName[index+1:]) + contextualBind.Regex, err = regexp.Compile(value) + if err != nil { + return err + } + } else { + return fmt.Errorf("Invalid Bind Context regex in %s", sectionName) + } + + switch sectionName[len(baseName)+1 : index] { + case "account": + acctName := sectionName[index+1:] + valid := false + for _, acctConf := range Accounts { + matches := contextualBind.Regex.FindString(acctConf.Name) + if matches != "" { + valid = true + } + } + if !valid { + log.Warnf("binds.conf: unexistent account: %s", acctName) + continue + } + contextualBind.ContextType = bindsContextAccount + case "folder": + // No validation needed. If the folder doesn't exist, the binds + // never get used + contextualBind.ContextType = bindsContextFolder + default: + return fmt.Errorf("Unknown Context Bind Section: %s", sectionName) + } + b.contextualBinds = append(b.contextualBinds, &contextualBind) + b.contextualCounts[contextualBind.ContextType]++ + } + + return nil +} + +func NewKeyBindings() *KeyBindings { + return &KeyBindings{ + ExKey: KeyStroke{0, ':'}, + CompleteKey: KeyStroke{0, vaxis.KeyTab}, + Globals: true, + contextualCache: make(map[bindsContextKey]*KeyBindings), + contextualCounts: make(map[bindsContextType]int), + } +} + +func areBindingsInputsEqual(a, b *Binding) bool { + if len(a.Input) != len(b.Input) { + return false + } + + for idx := range a.Input { + if a.Input[idx] != b.Input[idx] { + return false + } + } + + return true +} + +// this scans the bindings slice for copies and leaves just the first ones +// it also removes empty bindings, the ones that do nothing, so you can +// override and erase parent bindings with the context ones +func filterAndCleanBindings(bindings []*Binding) []*Binding { + // 1. remove a binding if we already have one with the same input + res1 := []*Binding{} + for _, b := range bindings { + // do we already have one here? + found := false + for _, r := range res1 { + if areBindingsInputsEqual(b, r) { + found = true + break + } + } + + // add it if we don't + if !found { + res1 = append(res1, b) + } + } + + // 2. clean up the empty bindings + res2 := []*Binding{} + for _, b := range res1 { + if len(b.Output) > 0 { + res2 = append(res2, b) + } + } + + return res2 +} + +func MergeBindings(bindings ...*KeyBindings) *KeyBindings { + merged := NewKeyBindings() + for _, b := range bindings { + merged.Bindings = append(merged.Bindings, b.Bindings...) + if !b.Globals { + break + } + } + merged.Bindings = filterAndCleanBindings(merged.Bindings) + merged.ExKey = bindings[0].ExKey + merged.CompleteKey = bindings[0].CompleteKey + merged.Globals = bindings[0].Globals + for _, b := range bindings { + merged.contextualBinds = append(merged.contextualBinds, b.contextualBinds...) + for t, c := range b.contextualCounts { + merged.contextualCounts[t] += c + } + } + return merged +} + +func (base *KeyBindings) contextual( + contextType bindsContextType, reg string, +) *KeyBindings { + if base.contextualCounts[contextType] == 0 { + // shortcut if no contextual binds for that type + return base + } + + key := bindsContextKey{ctxType: contextType, value: reg} + c, found := base.contextualCache[key] + if found { + return c + } + + c = base + for _, contextualBind := range base.contextualBinds { + if contextualBind.ContextType != contextType { + continue + } + if !contextualBind.Regex.Match([]byte(reg)) { + continue + } + c = MergeBindings(contextualBind.Bindings, c) + } + base.contextualCache[key] = c + + return c +} + +func (bindings *KeyBindings) ForAccount(account string) *KeyBindings { + return bindings.contextual(bindsContextAccount, account) +} + +func (bindings *KeyBindings) ForFolder(folder string) *KeyBindings { + return bindings.contextual(bindsContextFolder, folder) +} + +func (bindings *KeyBindings) Add(binding *Binding) { + // TODO: Search for conflicts? + bindings.Bindings = append(bindings.Bindings, binding) +} + +func (bindings *KeyBindings) GetBinding( + input []KeyStroke, +) (BindingSearchResult, []KeyStroke) { + incomplete := false + // TODO: This could probably be a sorted list to speed things up + // TODO: Deal with bindings that share a prefix + for _, binding := range bindings.Bindings { + if len(binding.Input) < len(input) { + continue + } + for i, stroke := range input { + if stroke.Modifiers != binding.Input[i].Modifiers { + goto next + } + if stroke.Key != binding.Input[i].Key { + goto next + } + } + if len(binding.Input) != len(input) { + incomplete = true + } else { + return BINDING_FOUND, binding.Output + } + next: + } + if incomplete { + return BINDING_INCOMPLETE, nil + } + return BINDING_NOT_FOUND, nil +} + +func (bindings *KeyBindings) GetReverseBindings(output []KeyStroke) [][]KeyStroke { + var inputs [][]KeyStroke + + for _, binding := range bindings.Bindings { + if len(binding.Output) != len(output) { + continue + } + for i, stroke := range output { + if stroke.Modifiers != binding.Output[i].Modifiers { + goto next + } + if stroke.Key != binding.Output[i].Key { + goto next + } + } + inputs = append(inputs, binding.Input) + next: + } + return inputs +} + +func FormatKeyStrokes(keystrokes []KeyStroke) string { + var sb strings.Builder + + for _, stroke := range keystrokes { + special := false + s := "" + for name, ks := range keyNames { + if (ks.Modifiers == stroke.Modifiers || ks.Modifiers == vaxis.ModifierMask(0)) && ks.Key == stroke.Key { + switch name { + case "cr": + special = true + s = "enter" + case "space": + s = " " + case "semicolon": + s = ";" + default: + special = true + s = name + } + // remove any modifiers this named key comes + // with so we format properly + stroke.Modifiers &^= ks.Modifiers + break + } + } + if stroke.Modifiers != vaxis.ModifierMask(0) { + special = true + } + if special { + sb.WriteString("<") + } + if stroke.Modifiers&vaxis.ModCtrl > 0 { + sb.WriteString("c-") + } + if stroke.Modifiers&vaxis.ModAlt > 0 { + sb.WriteString("a-") + } + if stroke.Modifiers&vaxis.ModShift > 0 { + sb.WriteString("s-") + } + if s == "" && stroke.Key < unicode.MaxRune { + s = string(stroke.Key) + } + sb.WriteString(s) + if special { + sb.WriteString(">") + } + } + + // replace leading & trailing spaces with explicit <space> keystrokes + buf := sb.String() + match := spaceTrimRe.FindStringSubmatch(buf) + if len(match) == 4 { + prefix := strings.ReplaceAll(match[1], " ", "<space>") + suffix := strings.ReplaceAll(match[3], " ", "<space>") + buf = prefix + match[2] + suffix + } + + return buf +} + +var spaceTrimRe = regexp.MustCompile(`^(\s*)(.*?)(\s*)$`) + +var keyNames = map[string]KeyStroke{ + "space": {vaxis.ModifierMask(0), ' '}, + "semicolon": {vaxis.ModifierMask(0), ';'}, + "enter": {vaxis.ModifierMask(0), vaxis.KeyEnter}, + "up": {vaxis.ModifierMask(0), vaxis.KeyUp}, + "down": {vaxis.ModifierMask(0), vaxis.KeyDown}, + "right": {vaxis.ModifierMask(0), vaxis.KeyRight}, + "left": {vaxis.ModifierMask(0), vaxis.KeyLeft}, + "upleft": {vaxis.ModifierMask(0), vaxis.KeyUpLeft}, + "upright": {vaxis.ModifierMask(0), vaxis.KeyUpRight}, + "downleft": {vaxis.ModifierMask(0), vaxis.KeyDownLeft}, + "downright": {vaxis.ModifierMask(0), vaxis.KeyDownRight}, + "center": {vaxis.ModifierMask(0), vaxis.KeyCenter}, + "pgup": {vaxis.ModifierMask(0), vaxis.KeyPgUp}, + "pgdn": {vaxis.ModifierMask(0), vaxis.KeyPgDown}, + "home": {vaxis.ModifierMask(0), vaxis.KeyHome}, + "end": {vaxis.ModifierMask(0), vaxis.KeyEnd}, + "insert": {vaxis.ModifierMask(0), vaxis.KeyInsert}, + "delete": {vaxis.ModifierMask(0), vaxis.KeyDelete}, + "backspace": {vaxis.ModifierMask(0), vaxis.KeyBackspace}, + // "help": {vaxis.ModifierMask(0), vaxis.KeyHelp}, + "exit": {vaxis.ModifierMask(0), vaxis.KeyExit}, + "clear": {vaxis.ModifierMask(0), vaxis.KeyClear}, + "cancel": {vaxis.ModifierMask(0), vaxis.KeyCancel}, + "print": {vaxis.ModifierMask(0), vaxis.KeyPrint}, + "pause": {vaxis.ModifierMask(0), vaxis.KeyPause}, + "backtab": {vaxis.ModShift, vaxis.KeyTab}, + "f1": {vaxis.ModifierMask(0), vaxis.KeyF01}, + "f2": {vaxis.ModifierMask(0), vaxis.KeyF02}, + "f3": {vaxis.ModifierMask(0), vaxis.KeyF03}, + "f4": {vaxis.ModifierMask(0), vaxis.KeyF04}, + "f5": {vaxis.ModifierMask(0), vaxis.KeyF05}, + "f6": {vaxis.ModifierMask(0), vaxis.KeyF06}, + "f7": {vaxis.ModifierMask(0), vaxis.KeyF07}, + "f8": {vaxis.ModifierMask(0), vaxis.KeyF08}, + "f9": {vaxis.ModifierMask(0), vaxis.KeyF09}, + "f10": {vaxis.ModifierMask(0), vaxis.KeyF10}, + "f11": {vaxis.ModifierMask(0), vaxis.KeyF11}, + "f12": {vaxis.ModifierMask(0), vaxis.KeyF12}, + "f13": {vaxis.ModifierMask(0), vaxis.KeyF13}, + "f14": {vaxis.ModifierMask(0), vaxis.KeyF14}, + "f15": {vaxis.ModifierMask(0), vaxis.KeyF15}, + "f16": {vaxis.ModifierMask(0), vaxis.KeyF16}, + "f17": {vaxis.ModifierMask(0), vaxis.KeyF17}, + "f18": {vaxis.ModifierMask(0), vaxis.KeyF18}, + "f19": {vaxis.ModifierMask(0), vaxis.KeyF19}, + "f20": {vaxis.ModifierMask(0), vaxis.KeyF20}, + "f21": {vaxis.ModifierMask(0), vaxis.KeyF21}, + "f22": {vaxis.ModifierMask(0), vaxis.KeyF22}, + "f23": {vaxis.ModifierMask(0), vaxis.KeyF23}, + "f24": {vaxis.ModifierMask(0), vaxis.KeyF24}, + "f25": {vaxis.ModifierMask(0), vaxis.KeyF25}, + "f26": {vaxis.ModifierMask(0), vaxis.KeyF26}, + "f27": {vaxis.ModifierMask(0), vaxis.KeyF27}, + "f28": {vaxis.ModifierMask(0), vaxis.KeyF28}, + "f29": {vaxis.ModifierMask(0), vaxis.KeyF29}, + "f30": {vaxis.ModifierMask(0), vaxis.KeyF30}, + "f31": {vaxis.ModifierMask(0), vaxis.KeyF31}, + "f32": {vaxis.ModifierMask(0), vaxis.KeyF32}, + "f33": {vaxis.ModifierMask(0), vaxis.KeyF33}, + "f34": {vaxis.ModifierMask(0), vaxis.KeyF34}, + "f35": {vaxis.ModifierMask(0), vaxis.KeyF35}, + "f36": {vaxis.ModifierMask(0), vaxis.KeyF36}, + "f37": {vaxis.ModifierMask(0), vaxis.KeyF37}, + "f38": {vaxis.ModifierMask(0), vaxis.KeyF38}, + "f39": {vaxis.ModifierMask(0), vaxis.KeyF39}, + "f40": {vaxis.ModifierMask(0), vaxis.KeyF40}, + "f41": {vaxis.ModifierMask(0), vaxis.KeyF41}, + "f42": {vaxis.ModifierMask(0), vaxis.KeyF42}, + "f43": {vaxis.ModifierMask(0), vaxis.KeyF43}, + "f44": {vaxis.ModifierMask(0), vaxis.KeyF44}, + "f45": {vaxis.ModifierMask(0), vaxis.KeyF45}, + "f46": {vaxis.ModifierMask(0), vaxis.KeyF46}, + "f47": {vaxis.ModifierMask(0), vaxis.KeyF47}, + "f48": {vaxis.ModifierMask(0), vaxis.KeyF48}, + "f49": {vaxis.ModifierMask(0), vaxis.KeyF49}, + "f50": {vaxis.ModifierMask(0), vaxis.KeyF50}, + "f51": {vaxis.ModifierMask(0), vaxis.KeyF51}, + "f52": {vaxis.ModifierMask(0), vaxis.KeyF52}, + "f53": {vaxis.ModifierMask(0), vaxis.KeyF53}, + "f54": {vaxis.ModifierMask(0), vaxis.KeyF54}, + "f55": {vaxis.ModifierMask(0), vaxis.KeyF55}, + "f56": {vaxis.ModifierMask(0), vaxis.KeyF56}, + "f57": {vaxis.ModifierMask(0), vaxis.KeyF57}, + "f58": {vaxis.ModifierMask(0), vaxis.KeyF58}, + "f59": {vaxis.ModifierMask(0), vaxis.KeyF59}, + "f60": {vaxis.ModifierMask(0), vaxis.KeyF60}, + "f61": {vaxis.ModifierMask(0), vaxis.KeyF61}, + "f62": {vaxis.ModifierMask(0), vaxis.KeyF62}, + "f63": {vaxis.ModifierMask(0), vaxis.KeyF63}, + "tab": {vaxis.ModifierMask(0), vaxis.KeyTab}, + "cr": {vaxis.ModifierMask(0), vaxis.KeyEnter}, + "esc": {vaxis.ModifierMask(0), vaxis.KeyEsc}, + "del": {vaxis.ModifierMask(0), vaxis.KeyDelete}, +} + +func ParseKeyStrokes(keystrokes string) ([]KeyStroke, error) { + var strokes []KeyStroke + buf := bytes.NewBufferString(keystrokes) + for { + tok, _, err := buf.ReadRune() + if err == io.EOF { + break + } else if err != nil { + return nil, err + } + // TODO: make it possible to bind to < or > themselves (and default to + // switching accounts) + switch tok { + case '<': + name, err := buf.ReadString(byte('>')) + switch { + case err == io.EOF: + return nil, errors.New("Expecting '>'") + case err != nil: + return nil, err + case name == ">": + return nil, errors.New("Expected a key name") + } + name = name[:len(name)-1] + args := strings.Split(name, "-") + // check if the last char was a '-' and we'll add it + // back. We check for "--" in case it was an invalid + // keystroke (ie <C->) + if strings.HasSuffix(name, "--") { + args = append(args, "-") + } + ks := KeyStroke{} + for i, arg := range args { + if i == len(args)-1 { + key, ok := keyNames[strings.ToLower(arg)] + if !ok { + r, n := utf8.DecodeRuneInString(arg) + if n != len(arg) { + return nil, fmt.Errorf("Unknown key '%s'", name) + } + key = KeyStroke{Key: r} + } + ks.Key = key.Key + ks.Modifiers |= key.Modifiers + strokes = append(strokes, ks) + } + switch strings.ToLower(arg) { + case "s", "S": + ks.Modifiers |= vaxis.ModShift + case "a", "A": + ks.Modifiers |= vaxis.ModAlt + case "c", "C": + ks.Modifiers |= vaxis.ModCtrl + } + } + case '>': + return nil, errors.New("Found '>' without '<'") + case '\\': + tok, _, err = buf.ReadRune() + if err == io.EOF { + tok = '\\' + } else if err != nil { + return nil, err + } + fallthrough + default: + strokes = append(strokes, KeyStroke{ + Modifiers: vaxis.ModifierMask(0), + Key: tok, + }) + } + } + return strokes, nil +} + +func ParseBinding(input, output, annotation string) (*Binding, error) { + in, err := ParseKeyStrokes(input) + if err != nil { + return nil, err + } + out, err := ParseKeyStrokes(output) + if err != nil { + return nil, err + } + return &Binding{ + Input: in, + Output: out, + Annotation: annotation, + }, nil +} diff --git a/config/binds_test.go b/config/binds_test.go new file mode 100644 index 0000000..a47da41 --- /dev/null +++ b/config/binds_test.go @@ -0,0 +1,103 @@ +package config + +import ( + "fmt" + "testing" + + "git.sr.ht/~rockorager/vaxis" + "github.com/stretchr/testify/assert" +) + +func TestGetBinding(t *testing.T) { + assert := assert.New(t) + + bindings := NewKeyBindings() + add := func(binding, cmd string) { + b, err := ParseBinding(binding, cmd, "") + if err != nil { + t.Fatal(err) + } + bindings.Add(b) + } + + add("abc", ":abc") + add("cba", ":cba") + add("foo", ":foo") + add("bar", ":bar") + + test := func(input []KeyStroke, result int, output string) { + _output, _ := ParseKeyStrokes(output) + r, out := bindings.GetBinding(input) + assert.Equal(result, int(r), fmt.Sprintf( + "%s: Expected result %d, got %d", output, result, r)) + assert.Equal(_output, out, fmt.Sprintf( + "%s: Expected output %v, got %v", output, _output, out)) + } + + test([]KeyStroke{ + {vaxis.ModifierMask(0), 'a'}, + }, BINDING_INCOMPLETE, "") + test([]KeyStroke{ + {vaxis.ModifierMask(0), 'a'}, + {vaxis.ModifierMask(0), 'b'}, + {vaxis.ModifierMask(0), 'c'}, + }, BINDING_FOUND, ":abc") + test([]KeyStroke{ + {vaxis.ModifierMask(0), 'c'}, + {vaxis.ModifierMask(0), 'b'}, + {vaxis.ModifierMask(0), 'a'}, + }, BINDING_FOUND, ":cba") + test([]KeyStroke{ + {vaxis.ModifierMask(0), 'f'}, + {vaxis.ModifierMask(0), 'o'}, + }, BINDING_INCOMPLETE, "") + test([]KeyStroke{ + {vaxis.ModifierMask(0), '4'}, + {vaxis.ModifierMask(0), '0'}, + {vaxis.ModifierMask(0), '4'}, + }, BINDING_NOT_FOUND, "") + + add("<C-a>", "c-a") + add("<C-Down>", ":next") + add("<C-PgUp>", ":prev") + add("<C-Enter>", ":open") + add("<C-->", ":open") + add("<S-up>", ":open") + test([]KeyStroke{ + {vaxis.ModCtrl, 'a'}, + }, BINDING_FOUND, "c-a") + test([]KeyStroke{ + {vaxis.ModCtrl, vaxis.KeyDown}, + }, BINDING_FOUND, ":next") + test([]KeyStroke{ + {vaxis.ModCtrl, vaxis.KeyPgUp}, + }, BINDING_FOUND, ":prev") + test([]KeyStroke{ + {vaxis.ModCtrl, vaxis.KeyPgDown}, + }, BINDING_NOT_FOUND, "") + test([]KeyStroke{ + {vaxis.ModCtrl, vaxis.KeyEnter}, + }, BINDING_FOUND, ":open") + test([]KeyStroke{ + {vaxis.ModCtrl, '-'}, + }, BINDING_FOUND, ":open") + test([]KeyStroke{ + {vaxis.ModShift, vaxis.KeyUp}, + }, BINDING_FOUND, ":open") +} + +func TestKeyStrokeFormatting(t *testing.T) { + tests := []struct { + stroke KeyStroke + formatted string + }{ + {KeyStroke{vaxis.ModifierMask(0), vaxis.KeyLeft}, "<left>"}, + {KeyStroke{vaxis.ModCtrl, vaxis.KeyLeft}, "<c-left>"}, + {KeyStroke{vaxis.ModCtrl, 'e'}, "<c-e>"}, + {KeyStroke{vaxis.ModifierMask(0), vaxis.KeySpace}, "<space>"}, + } + + for _, test := range tests { + assert.Equal(t, test.formatted, FormatKeyStrokes([]KeyStroke{test.stroke})) + } +} diff --git a/config/cmds.go b/config/cmds.go new file mode 100644 index 0000000..1620891 --- /dev/null +++ b/config/cmds.go @@ -0,0 +1,22 @@ +package config + +import ( + "os" +) + +func EditorCmds() []string { + return []string{ + Compose.Editor, + os.Getenv("EDITOR"), + "vi", + "nano", + } +} + +func PagerCmds() []string { + return []string{ + Viewer.Pager, + os.Getenv("PAGER"), + "less -Rc", + } +} diff --git a/config/columns.go b/config/columns.go new file mode 100644 index 0000000..c909d0a --- /dev/null +++ b/config/columns.go @@ -0,0 +1,118 @@ +package config + +import ( + "bytes" + "fmt" + "regexp" + "strconv" + "strings" + "text/template" + + "git.sr.ht/~rjarry/aerc/lib/templates" + "github.com/go-ini/ini" +) + +type ColumnFlags uint32 + +func (f ColumnFlags) Has(o ColumnFlags) bool { return f&o == o } + +const ( + ALIGN_LEFT ColumnFlags = 1 << iota + ALIGN_CENTER + ALIGN_RIGHT + WIDTH_AUTO // whatever is left + WIDTH_FRACTION // ratio of total width + WIDTH_EXACT // exact number of characters + WIDTH_FIT // fit to column content width +) + +type ColumnDef struct { + Name string + Flags ColumnFlags + Width float64 + Template *template.Template +} + +var columnRe = regexp.MustCompile(`^([\w-]+)(?:([<:>])(=|\*|\d+%?)?)?$`) + +func parseColumnDef(col string, section *ini.Section) (*ColumnDef, error) { + col = strings.TrimSpace(col) + match := columnRe.FindStringSubmatch(col) + if match == nil { + return nil, fmt.Errorf("invalid column def: %v", col) + } + name := match[1] + keyName := fmt.Sprintf("column-%s", name) + + var flags ColumnFlags + switch match[2] { + case "<", "": + flags |= ALIGN_LEFT + case ":": + flags |= ALIGN_CENTER + case ">": + flags |= ALIGN_RIGHT + } + + var width float64 = 0 + switch match[3] { + case "=": + flags |= WIDTH_FIT + case "*", "": + flags |= WIDTH_AUTO + default: + s := match[3] + var divider float64 = 1 + if strings.HasSuffix(s, "%") { + divider = 100 + s = strings.TrimSuffix(s, "%") + flags |= WIDTH_FRACTION + } else { + flags |= WIDTH_EXACT + } + w, err := strconv.ParseFloat(s, 64) + if err != nil { + return nil, fmt.Errorf("%s: %w", keyName, err) + } + if divider == 100 && w > 100 { + return nil, fmt.Errorf("%s: invalid width %.0f%%", keyName, w) + } + width = w / divider + } + key, err := section.GetKey(keyName) + if err != nil { + return nil, err + } + + t, err := templates.ParseTemplate(keyName, key.String()) + if err != nil { + return nil, err + } + + err = templates.Render(t, &bytes.Buffer{}, &dummyData{}) + if err != nil { + return nil, err + } + + return &ColumnDef{ + Name: name, + Flags: flags, + Width: width, + Template: t, + }, nil +} + +func ParseColumnDefs(key *ini.Key, section *ini.Section) ([]*ColumnDef, error) { + var columns []*ColumnDef + for _, col := range key.Strings(",") { + c, err := parseColumnDef(col, section) + if err != nil { + return nil, err + } + columns = append(columns, c) + } + if len(columns) == 0 { + return nil, nil + } + return columns, nil +} diff --git a/config/compose.go b/config/compose.go new file mode 100644 index 0000000..db963f5 --- /dev/null +++ b/config/compose.go @@ -0,0 +1,44 @@ +package config + +import ( + "regexp" + + "git.sr.ht/~rjarry/aerc/lib/log" + "github.com/go-ini/ini" +) + +type ComposeConfig struct { + Editor string `ini:"editor"` + HeaderLayout [][]string `ini:"header-layout" parse:"ParseLayout" default:"To|From,Subject"` + AddressBookCmd string `ini:"address-book-cmd"` + ReplyToSelf bool `ini:"reply-to-self" default:"true"` + NoAttachmentWarning *regexp.Regexp `ini:"no-attachment-warning" parse:"ParseNoAttachmentWarning"` + EmptySubjectWarning bool `ini:"empty-subject-warning"` + FilePickerCmd string `ini:"file-picker-cmd"` + FormatFlowed bool `ini:"format-flowed"` + EditHeaders bool `ini:"edit-headers"` + FocusBody bool `ini:"focus-body"` + LFEditor bool `ini:"lf-editor"` +} + +var Compose = new(ComposeConfig) + +func parseCompose(file *ini.File) error { + if err := MapToStruct(file.Section("compose"), Compose, true); err != nil { + return err + } + log.Debugf("aerc.conf: [compose] %#v", Compose) + return nil +} + +func (c *ComposeConfig) ParseLayout(sec *ini.Section, key *ini.Key) ([][]string, error) { + layout := parseLayout(key.String()) + return layout, nil +} + +func (c *ComposeConfig) ParseNoAttachmentWarning(sec *ini.Section, key *ini.Key) (*regexp.Regexp, error) { + if key.String() == "" { + return nil, nil + } + return regexp.Compile(`(?im)` + key.String()) +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..14c4b23 --- /dev/null +++ b/config/config.go @@ -0,0 +1,178 @@ +package config + +import ( + "errors" + "fmt" + "os" + "path" + "strings" + + "git.sr.ht/~rjarry/aerc/lib/xdg" + "github.com/go-ini/ini" +) + +// Set at build time +var ( + shareDir string + libexecDir string +) + +func buildDefaultDirs() []string { + var defaultDirs []string + + prefixes := []string{ + xdg.ConfigPath(), + "~/.local/libexec", + xdg.DataPath(), + } + + // Add XDG_CONFIG_HOME and XDG_DATA_HOME + for _, v := range prefixes { + if v != "" { + defaultDirs = append(defaultDirs, xdg.ExpandHome(v, "aerc")) + } + } + + // Trim null chars inserted post-build by systems like Conda + shareDir := strings.TrimRight(shareDir, "\x00") + libexecDir := strings.TrimRight(libexecDir, "\x00") + + // Add custom buildtime dirs + if libexecDir != "" && libexecDir != "/usr/local/libexec/aerc" { + defaultDirs = append(defaultDirs, xdg.ExpandHome(libexecDir)) + } + if shareDir != "" && shareDir != "/usr/local/share/aerc" { + defaultDirs = append(defaultDirs, xdg.ExpandHome(shareDir)) + } + + // Add fixed fallback locations + defaultDirs = append(defaultDirs, "/usr/local/libexec/aerc") + defaultDirs = append(defaultDirs, "/usr/local/share/aerc") + defaultDirs = append(defaultDirs, "/usr/libexec/aerc") + defaultDirs = append(defaultDirs, "/usr/share/aerc") + + return defaultDirs +} + +var SearchDirs = buildDefaultDirs() + +func installTemplate(root, name string) error { + var err error + if _, err = os.Stat(root); os.IsNotExist(err) { + err = os.MkdirAll(root, 0o755) + if err != nil { + return err + } + } + var data []byte + for _, dir := range SearchDirs { + data, err = os.ReadFile(path.Join(dir, name)) + if err == nil { + break + } + } + if err != nil { + return err + } + err = os.WriteFile(path.Join(root, name), data, 0o644) + if err != nil { + return err + } + return nil +} + +func parseConf(filename string) error { + file, err := ini.LoadSources(ini.LoadOptions{ + KeyValueDelimiters: "=", + }, filename) + if err != nil { + return err + } + if err := parseGeneral(file); err != nil { + return err + } + if err := parseFilters(file); err != nil { + return err + } + if err := parseCompose(file); err != nil { + return err + } + if err := parseConverters(file); err != nil { + return err + } + if err := parseViewer(file); err != nil { + return err + } + if err := parseStatusline(file); err != nil { + return err + } + if err := parseOpeners(file); err != nil { + return err + } + if err := parseHooks(file); err != nil { + return err + } + if err := parseUi(file); err != nil { + return err + } + if err := parseTemplates(file); err != nil { + return err + } + return nil +} + +func LoadConfigFromFile( + root *string, accts []string, filename, bindPath, acctPath string, +) error { + if root == nil { + _root := xdg.ConfigPath("aerc") + root = &_root + } + if filename == "" { + filename = path.Join(*root, "aerc.conf") + // if it doesn't exist copy over the template, then load + if _, err := os.Stat(filename); errors.Is(err, os.ErrNotExist) { + fmt.Printf("%s not found, installing the system default\n", filename) + if err := installTemplate(*root, "aerc.conf"); err != nil { + return err + } + } + } + SetConfFilename(filename) + if err := parseConf(filename); err != nil { + return fmt.Errorf("%s: %w", filename, err) + } + if err := parseAccounts(*root, accts, acctPath); err != nil { + return err + } + if err := parseBinds(*root, bindPath); err != nil { + return err + } + return nil +} + +func parseLayout(layout string) [][]string { + rows := strings.Split(layout, ",") + l := make([][]string, len(rows)) + for i, r := range rows { + l[i] = strings.Split(r, "|") + } + return l +} + +func contains(list []string, v string) bool { + for _, item := range list { + if item == v { + return true + } + } + return false +} + +// warning message related to configuration (deprecation, etc.) +type Warning struct { + Title string + Body string +} + +var Warnings []Warning diff --git a/config/converters.go b/config/converters.go new file mode 100644 index 0000000..829361f --- /dev/null +++ b/config/converters.go @@ -0,0 +1,36 @@ +package config + +import ( + "fmt" + "strings" + + "git.sr.ht/~rjarry/aerc/lib/log" + "github.com/go-ini/ini" +) + +var Converters = make(map[string]string) + +func parseConverters(file *ini.File) error { + converters, err := file.GetSection("multipart-converters") + if err != nil { + goto out + } + + for mimeType, command := range converters.KeysHash() { + mimeType = strings.ToLower(mimeType) + if mimeType == "text/plain" { + return fmt.Errorf( + "multipart-converters: text/plain is reserved") + } + if !strings.HasPrefix(mimeType, "text/") { + return fmt.Errorf( + "multipart-converters: %q: only text/* MIME types are supported", + mimeType) + } + Converters[mimeType] = command + } + +out: + log.Debugf("aerc.conf: [multipart-converters] %#v", Converters) + return nil +} diff --git a/config/filters.go b/config/filters.go new file mode 100644 index 0000000..6037266 --- /dev/null +++ b/config/filters.go @@ -0,0 +1,96 @@ +package config + +import ( + "regexp" + "strings" + + "git.sr.ht/~rjarry/aerc/lib/log" + "github.com/go-ini/ini" +) + +type FilterType int + +const ( + FILTER_MIMETYPE FilterType = iota + FILTER_HEADER + FILTER_HEADERS + FILTER_FILENAME +) + +type FilterConfig struct { + Type FilterType + Filter string + Command string + NeedsPager bool + Header string + Regex *regexp.Regexp +} + +var Filters []*FilterConfig + +func parseFilters(file *ini.File) error { + filters, err := file.GetSection("filters") + if err != nil { + goto end + } + + for _, key := range filters.Keys() { + pager := true + cmd := key.Value() + if strings.HasPrefix(cmd, "!") { + cmd = strings.TrimLeft(cmd, "! \t") + pager = false + } + filter := FilterConfig{ + Command: cmd, + NeedsPager: pager, + Filter: key.Name(), + } + + switch { + case strings.HasPrefix(filter.Filter, ".filename,~"): + filter.Type = FILTER_FILENAME + regex := filter.Filter[strings.Index(filter.Filter, "~")+1:] + filter.Regex, err = regexp.Compile(regex) + if err != nil { + return err + } + case strings.HasPrefix(filter.Filter, ".filename,"): + filter.Type = FILTER_FILENAME + value := filter.Filter[strings.Index(filter.Filter, ",")+1:] + filter.Regex, err = regexp.Compile(regexp.QuoteMeta(value)) + if err != nil { + return err + } + case strings.Contains(filter.Filter, ",~"): + filter.Type = FILTER_HEADER + //nolint:gocritic // guarded by strings.Contains + header := filter.Filter[:strings.Index(filter.Filter, ",")] + regex := filter.Filter[strings.Index(filter.Filter, "~")+1:] + filter.Header = strings.ToLower(header) + filter.Regex, err = regexp.Compile(regex) + if err != nil { + return err + } + case strings.ContainsRune(filter.Filter, ','): + filter.Type = FILTER_HEADER + //nolint:gocritic // guarded by strings.Contains + header := filter.Filter[:strings.Index(filter.Filter, ",")] + value := filter.Filter[strings.Index(filter.Filter, ",")+1:] + filter.Header = strings.ToLower(header) + filter.Regex, err = regexp.Compile(regexp.QuoteMeta(value)) + if err != nil { + return err + } + case filter.Filter == ".headers": + filter.Type = FILTER_HEADERS + default: + filter.Type = FILTER_MIMETYPE + } + Filters = append(Filters, &filter) + } + +end: + log.Debugf("aerc.conf: [filters] %#v", Filters) + return nil +} diff --git a/config/general.go b/config/general.go new file mode 100644 index 0000000..7d86b0f --- /dev/null +++ b/config/general.go @@ -0,0 +1,79 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/xdg" + "github.com/go-ini/ini" + "github.com/mattn/go-isatty" +) + +type GeneralConfig struct { + DefaultSavePath string `ini:"default-save-path"` + PgpProvider string `ini:"pgp-provider" default:"auto" parse:"ParsePgpProvider"` + UnsafeAccountsConf bool `ini:"unsafe-accounts-conf"` + LogFile string `ini:"log-file"` + LogLevel log.LogLevel `ini:"log-level" default:"info" parse:"ParseLogLevel"` + DisableIPC bool `ini:"disable-ipc"` + DisableIPCMailto bool `ini:"disable-ipc-mailto"` + DisableIPCMbox bool `ini:"disable-ipc-mbox"` + EnableOSC8 bool `ini:"enable-osc8" default:"false"` + Term string `ini:"term" default:"xterm-256color"` + DefaultMenuCmd string `ini:"default-menu-cmd"` + QuakeMode bool `ini:"enable-quake-mode" default:"false"` + UsePinentry bool `ini:"use-terminal-pinentry" default:"false"` +} + +var General = new(GeneralConfig) + +func parseGeneral(file *ini.File) error { + var logFile *os.File + + if err := MapToStruct(file.Section("general"), General, true); err != nil { + return err + } + + useStdout := false + if !isatty.IsTerminal(os.Stdout.Fd()) { + logFile = os.Stdout + useStdout = true + // redirected to file, force TRACE level + General.LogLevel = log.TRACE + } else if General.LogFile != "" { + var err error + path := xdg.ExpandHome(General.LogFile) + err = os.MkdirAll(filepath.Dir(path), 0o700) + if err != nil { + return fmt.Errorf("log-file: %w", err) + } + logFile, err = os.OpenFile(path, + os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + return err + } + } + + err := log.Init(logFile, useStdout, General.LogLevel) + if err != nil { + return err + } + + log.Debugf("aerc.conf: [general] %#v", General) + + return nil +} + +func (gen *GeneralConfig) ParseLogLevel(sec *ini.Section, key *ini.Key) (log.LogLevel, error) { + return log.ParseLevel(key.String()) +} + +func (gen *GeneralConfig) ParsePgpProvider(sec *ini.Section, key *ini.Key) (string, error) { + switch key.String() { + case "gpg", "internal", "auto": + return key.String(), nil + } + return "", fmt.Errorf("must be either auto, gpg or internal") +} diff --git a/config/hooks.go b/config/hooks.go new file mode 100644 index 0000000..ea291e9 --- /dev/null +++ b/config/hooks.go @@ -0,0 +1,29 @@ +package config + +import ( + "git.sr.ht/~rjarry/aerc/lib/log" + "github.com/go-ini/ini" +) + +type HooksConfig struct { + AercStartup string `ini:"aerc-startup"` + AercShutdown string `ini:"aerc-shutdown"` + FlagChanged string `ini:"flag-changed"` + MailReceived string `ini:"mail-received"` + MailDeleted string `ini:"mail-deleted"` + MailAdded string `ini:"mail-added"` + MailSent string `ini:"mail-sent"` + TagModified string `ini:"tag-modified"` +} + +var Hooks HooksConfig + +func parseHooks(file *ini.File) error { + err := MapToStruct(file.Section("hooks"), &Hooks, true) + if err != nil { + return err + } + + log.Debugf("aerc.conf: [hooks] %#v", Hooks) + return nil +} diff --git a/config/openers.go b/config/openers.go new file mode 100644 index 0000000..95273aa --- /dev/null +++ b/config/openers.go @@ -0,0 +1,31 @@ +package config + +import ( + "strings" + + "git.sr.ht/~rjarry/aerc/lib/log" + "github.com/go-ini/ini" +) + +type Opener struct { + Mime string + Args string +} + +var Openers []Opener + +func parseOpeners(file *ini.File) error { + openers, err := file.GetSection("openers") + if err != nil { + goto out + } + + for _, key := range openers.Keys() { + mime := strings.ToLower(key.Name()) + Openers = append(Openers, Opener{Mime: mime, Args: key.Value()}) + } + +out: + log.Debugf("aerc.conf: [openers] %#v", Openers) + return nil +} diff --git a/config/parse.go b/config/parse.go new file mode 100644 index 0000000..d836c76 --- /dev/null +++ b/config/parse.go @@ -0,0 +1,233 @@ +package config + +import ( + "errors" + "fmt" + "reflect" + "regexp" + + "git.sr.ht/~rjarry/aerc/lib/templates" + "github.com/emersion/go-message/mail" + "github.com/go-ini/ini" +) + +func MapToStruct(s *ini.Section, v interface{}, useDefaults bool) error { + typ := reflect.TypeOf(v) + val := reflect.ValueOf(v) + if typ.Kind() == reflect.Ptr { + typ = typ.Elem() + val = val.Elem() + } else { + panic("MapToStruct requires a pointer") + } + if typ.Kind() != reflect.Struct { + panic("MapToStruct requires a pointer to a struct") + } + + for i := 0; i < typ.NumField(); i++ { + fieldVal := val.Field(i) + fieldType := typ.Field(i) + + name := fieldType.Tag.Get("ini") + if name == "" || name == "-" { + continue + } + key, err := s.GetKey(name) + if err != nil { + defValue, found := fieldType.Tag.Lookup("default") + if useDefaults && found { + key, _ = s.NewKey(name, defValue) + } else { + continue + } + } + err = setField(s, key, reflect.ValueOf(v), fieldVal, fieldType) + if err != nil { + return fmt.Errorf("[%s].%s: %w", s.Name(), name, err) + } + } + return nil +} + +func setField( + s *ini.Section, key *ini.Key, struc reflect.Value, + fieldVal reflect.Value, fieldType reflect.StructField, +) error { + var methodValue reflect.Value + method := getParseMethod(s, key, struc, fieldType) + if method.IsValid() { + in := []reflect.Value{reflect.ValueOf(s), reflect.ValueOf(key)} + out := method.Call(in) + err, _ := out[1].Interface().(error) + if err != nil { + return err + } + methodValue = out[0] + } + + ft := fieldType.Type + + switch ft.Kind() { + case reflect.String: + if method.IsValid() { + fieldVal.SetString(methodValue.String()) + } else { + fieldVal.SetString(key.String()) + } + case reflect.Bool: + if method.IsValid() { + fieldVal.SetBool(methodValue.Bool()) + } else { + boolVal, err := key.Bool() + if err != nil { + return err + } + fieldVal.SetBool(boolVal) + } + case reflect.Int32: + // impossible to differentiate rune from int32, they are aliases + // this is an ugly hack but there is no alternative... + if fieldType.Tag.Get("type") == "rune" { + if method.IsValid() { + fieldVal.Set(methodValue) + } else { + runes := []rune(key.String()) + if len(runes) != 1 { + return errors.New("value must be 1 character long") + } + fieldVal.Set(reflect.ValueOf(runes[0])) + } + return nil + } + fallthrough + case reflect.Int64: + // ParseDuration will not return err for `0`, so check the type name + if ft.PkgPath() == "time" && ft.Name() == "Duration" { + durationVal, err := key.Duration() + if err != nil { + return err + } + fieldVal.Set(reflect.ValueOf(durationVal)) + return nil + } + fallthrough + case reflect.Int, reflect.Int8, reflect.Int16: + if method.IsValid() { + fieldVal.SetInt(methodValue.Int()) + } else { + intVal, err := key.Int64() + if err != nil { + return err + } + fieldVal.SetInt(intVal) + } + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + if method.IsValid() { + fieldVal.SetUint(methodValue.Uint()) + } else { + uintVal, err := key.Uint64() + if err != nil { + return err + } + fieldVal.SetUint(uintVal) + } + case reflect.Float32, reflect.Float64: + if method.IsValid() { + fieldVal.SetFloat(methodValue.Float()) + } else { + floatVal, err := key.Float64() + if err != nil { + return err + } + fieldVal.SetFloat(floatVal) + } + case reflect.Slice, reflect.Array: + switch { + case method.IsValid(): + fieldVal.Set(methodValue) + case ft.Elem().Kind() == reflect.Ptr && + typePath(ft.Elem().Elem()) == "net/mail.Address": + addrs, err := mail.ParseAddressList(key.String()) + if err != nil { + return err + } + fieldVal.Set(reflect.ValueOf(addrs)) + case ft.Elem().Kind() == reflect.String: + delim := fieldType.Tag.Get("delim") + fieldVal.Set(reflect.ValueOf(key.Strings(delim))) + default: + panic(fmt.Sprintf("unsupported type []%s", typePath(ft.Elem()))) + } + case reflect.Struct: + if method.IsValid() { + fieldVal.Set(methodValue) + } else { + panic(fmt.Sprintf("unsupported type %s", typePath(ft))) + } + case reflect.Ptr: + if method.IsValid() { + fieldVal.Set(methodValue) + } else { + switch typePath(ft.Elem()) { + case "net/mail.Address": + addr, err := mail.ParseAddress(key.String()) + if err != nil { + return err + } + fieldVal.Set(reflect.ValueOf(addr)) + case "regexp.Regexp": + r, err := regexp.Compile(key.String()) + if err != nil { + return err + } + fieldVal.Set(reflect.ValueOf(r)) + case "text/template.Template": + t, err := templates.ParseTemplate(key.String(), key.String()) + if err != nil { + return err + } + fieldVal.Set(reflect.ValueOf(t)) + default: + panic(fmt.Sprintf("unsupported type %s", typePath(ft))) + } + } + default: + panic(fmt.Sprintf("unsupported type %s", typePath(ft))) + } + return nil +} + +func getParseMethod( + section *ini.Section, key *ini.Key, + struc reflect.Value, typ reflect.StructField, +) reflect.Value { + methodName, found := typ.Tag.Lookup("parse") + if !found { + return reflect.Value{} + } + method := struc.MethodByName(methodName) + if !method.IsValid() { + panic(fmt.Sprintf("(*%s).%s: method not found", + struc, methodName)) + } + + if method.Type().NumIn() != 2 || + method.Type().In(0) != reflect.TypeOf(section) || + method.Type().In(1) != reflect.TypeOf(key) || + method.Type().NumOut() != 2 { + panic(fmt.Sprintf("(*%s).%s: invalid signature, expected %s", + struc.Elem().Type().Name(), methodName, + "func(*ini.Section, *ini.Key) (any, error)")) + } + + return method +} + +func typePath(t reflect.Type) string { + var prefix string + if t.Kind() == reflect.Ptr { + t = t.Elem() + prefix = "*" + } + return fmt.Sprintf("%s%s.%s", prefix, t.PkgPath(), t.Name()) +} diff --git a/config/reload.go b/config/reload.go new file mode 100644 index 0000000..f572cea --- /dev/null +++ b/config/reload.go @@ -0,0 +1,68 @@ +package config + +import ( + "errors" + "os" + "path/filepath" + + "git.sr.ht/~rjarry/aerc/lib/log" +) + +type reloadStore struct { + binds string + conf string +} + +var rlst reloadStore + +func SetBindsFilename(fn string) { + log.Debugf("reloader: set binds file: %s", fn) + rlst.binds = fn +} + +func SetConfFilename(fn string) { + log.Debugf("reloader: set conf file: %s", fn) + rlst.conf = fn +} + +func ReloadBinds() (string, error) { + f := rlst.binds + if !exists(f) { + return f, os.ErrNotExist + } + log.Debugf("reload binds file: %s", f) + Binds = defaultBindsConfig() + return f, parseBindsFromFile(filepath.Dir(f), f) +} + +func ReloadConf() (string, error) { + f := rlst.conf + if !exists(f) { + return f, os.ErrNotExist + } + log.Debugf("reload conf file: %s", f) + + General = new(GeneralConfig) + Filters = nil + Compose = new(ComposeConfig) + Converters = make(map[string]string) + Viewer = new(ViewerConfig) + Statusline = new(StatuslineConfig) + Openers = nil + Hooks = HooksConfig{} + Ui = defaultUIConfig() + Templates = new(TemplateConfig) + + return f, parseConf(f) +} + +func ReloadAccounts() error { + return errors.New("not implemented") +} + +func exists(fn string) bool { + if _, err := os.Stat(fn); errors.Is(err, os.ErrNotExist) { + return false + } + return true +} diff --git a/config/statusline.go b/config/statusline.go new file mode 100644 index 0000000..964f3f5 --- /dev/null +++ b/config/statusline.go @@ -0,0 +1,38 @@ +package config + +import ( + "git.sr.ht/~rjarry/aerc/lib/log" + "github.com/go-ini/ini" +) + +type StatuslineConfig struct { + StatusColumns []*ColumnDef `ini:"status-columns" parse:"ParseColumns" default:"left<*,center>=,right>*"` + ColumnSeparator string `ini:"column-separator" default:" "` + Separator string `ini:"separator" default:" | "` + DisplayMode string `ini:"display-mode" default:"text"` +} + +var Statusline = new(StatuslineConfig) + +func parseStatusline(file *ini.File) error { + statusline := file.Section("statusline") + if err := MapToStruct(statusline, Statusline, true); err != nil { + return err + } + + log.Debugf("aerc.conf: [statusline] %#v", Statusline) + return nil +} + +func (s *StatuslineConfig) ParseColumns(sec *ini.Section, key *ini.Key) ([]*ColumnDef, error) { + if !sec.HasKey("column-left") { + _, _ = sec.NewKey("column-left", "[{{.Account}}] {{.StatusInfo}}") + } + if !sec.HasKey("column-center") { + _, _ = sec.NewKey("column-center", "{{.PendingKeys}}") + } + if !sec.HasKey("column-right") { + _, _ = sec.NewKey("column-right", "{{.TrayInfo}} | {{cwd}}") + } + return ParseColumnDefs(key, sec) +} diff --git a/config/style.go b/config/style.go new file mode 100644 index 0000000..0382c95 --- /dev/null +++ b/config/style.go @@ -0,0 +1,769 @@ +package config + +import ( + "errors" + "fmt" + "maps" + "os" + "regexp" + "strconv" + "strings" + + "git.sr.ht/~rjarry/aerc/lib/xdg" + "git.sr.ht/~rockorager/vaxis" + "github.com/emersion/go-message/mail" + "github.com/go-ini/ini" +) + +type StyleObject int32 + +const ( + STYLE_DEFAULT StyleObject = iota + STYLE_ERROR + STYLE_WARNING + STYLE_SUCCESS + + STYLE_TITLE + STYLE_HEADER + + STYLE_STATUSLINE_DEFAULT + STYLE_STATUSLINE_ERROR + STYLE_STATUSLINE_WARNING + STYLE_STATUSLINE_SUCCESS + + STYLE_MSGLIST_DEFAULT + STYLE_MSGLIST_UNREAD + STYLE_MSGLIST_READ + STYLE_MSGLIST_FLAGGED + STYLE_MSGLIST_DELETED + STYLE_MSGLIST_MARKED + STYLE_MSGLIST_RESULT + STYLE_MSGLIST_ANSWERED + STYLE_MSGLIST_FORWARDED + STYLE_MSGLIST_THREAD_FOLDED + STYLE_MSGLIST_GUTTER + STYLE_MSGLIST_PILL + STYLE_MSGLIST_THREAD_CONTEXT + STYLE_MSGLIST_THREAD_ORPHAN + + STYLE_DIRLIST_DEFAULT + STYLE_DIRLIST_UNREAD + STYLE_DIRLIST_RECENT + + STYLE_PART_SWITCHER + STYLE_PART_FILENAME + STYLE_PART_MIMETYPE + + STYLE_COMPLETION_DEFAULT + STYLE_COMPLETION_DESCRIPTION + STYLE_COMPLETION_GUTTER + STYLE_COMPLETION_PILL + + STYLE_TAB + STYLE_STACK + STYLE_SPINNER + STYLE_BORDER + + STYLE_SELECTOR_DEFAULT + STYLE_SELECTOR_FOCUSED + STYLE_SELECTOR_CHOOSER +) + +var StyleNames = map[string]StyleObject{ + "default": STYLE_DEFAULT, + "error": STYLE_ERROR, + "warning": STYLE_WARNING, + "success": STYLE_SUCCESS, + + "title": STYLE_TITLE, + "header": STYLE_HEADER, + + "statusline_default": STYLE_STATUSLINE_DEFAULT, + "statusline_error": STYLE_STATUSLINE_ERROR, + "statusline_warning": STYLE_STATUSLINE_WARNING, + "statusline_success": STYLE_STATUSLINE_SUCCESS, + + "msglist_default": STYLE_MSGLIST_DEFAULT, + "msglist_unread": STYLE_MSGLIST_UNREAD, + "msglist_read": STYLE_MSGLIST_READ, + "msglist_flagged": STYLE_MSGLIST_FLAGGED, + "msglist_deleted": STYLE_MSGLIST_DELETED, + "msglist_marked": STYLE_MSGLIST_MARKED, + "msglist_result": STYLE_MSGLIST_RESULT, + "msglist_answered": STYLE_MSGLIST_ANSWERED, + "msglist_forwarded": STYLE_MSGLIST_FORWARDED, + "msglist_gutter": STYLE_MSGLIST_GUTTER, + "msglist_pill": STYLE_MSGLIST_PILL, + + "msglist_thread_folded": STYLE_MSGLIST_THREAD_FOLDED, + "msglist_thread_context": STYLE_MSGLIST_THREAD_CONTEXT, + "msglist_thread_orphan": STYLE_MSGLIST_THREAD_ORPHAN, + + "dirlist_default": STYLE_DIRLIST_DEFAULT, + "dirlist_unread": STYLE_DIRLIST_UNREAD, + "dirlist_recent": STYLE_DIRLIST_RECENT, + + "part_switcher": STYLE_PART_SWITCHER, + "part_filename": STYLE_PART_FILENAME, + "part_mimetype": STYLE_PART_MIMETYPE, + + "completion_default": STYLE_COMPLETION_DEFAULT, + "completion_description": STYLE_COMPLETION_DESCRIPTION, + "completion_gutter": STYLE_COMPLETION_GUTTER, + "completion_pill": STYLE_COMPLETION_PILL, + + "tab": STYLE_TAB, + "stack": STYLE_STACK, + "spinner": STYLE_SPINNER, + "border": STYLE_BORDER, + + "selector_default": STYLE_SELECTOR_DEFAULT, + "selector_focused": STYLE_SELECTOR_FOCUSED, + "selector_chooser": STYLE_SELECTOR_CHOOSER, +} + +type StyleHeaderPattern struct { + RawPattern string + Re *regexp.Regexp +} + +type Style struct { + Fg vaxis.Color + Bg vaxis.Color + Bold bool + Blink bool + Underline bool + Reverse bool + Italic bool + Dim bool + // Only for msglist, maps header -> pattern/regexp + // All regexps must match in order for the style to be applied + headerPatterns map[string]*StyleHeaderPattern +} + +func (s Style) Get() vaxis.Style { + vx := vaxis.Style{ + Foreground: s.Fg, + Background: s.Bg, + } + if s.Bold { + vx.Attribute |= vaxis.AttrBold + } + if s.Blink { + vx.Attribute |= vaxis.AttrBlink + } + if s.Underline { + vx.UnderlineStyle |= vaxis.UnderlineSingle + } + if s.Reverse { + vx.Attribute |= vaxis.AttrReverse + } + if s.Italic { + vx.Attribute |= vaxis.AttrItalic + } + if s.Dim { + vx.Attribute |= vaxis.AttrDim + } + return vx +} + +func (s *Style) Normal() { + s.Bold = false + s.Blink = false + s.Underline = false + s.Reverse = false + s.Italic = false + s.Dim = false +} + +func (s *Style) Default() *Style { + s.Fg = 0 + s.Bg = 0 + return s +} + +func (s *Style) Reset() *Style { + s.Default() + s.Normal() + return s +} + +func (s *Style) hasSameHeaderPatterns(other map[string]*StyleHeaderPattern) bool { + return maps.EqualFunc(s.headerPatterns, other, func(a, b *StyleHeaderPattern) bool { + return a.RawPattern == b.RawPattern + }) +} + +func boolSwitch(val string, cur_val bool) (bool, error) { + switch val { + case "true": + return true, nil + case "false": + return false, nil + case "toggle": + return !cur_val, nil + default: + return cur_val, errors.New( + "Bool Switch attribute must be true, false, or toggle") + } +} + +func extractColor(val string) vaxis.Color { + // Check if the string can be interpreted as a number, indicating a + // reference to the color number. Otherwise retrieve the number based + // on the name. + if i, err := strconv.ParseUint(val, 10, 8); err == nil { + return vaxis.IndexColor(uint8(i)) + } + if strings.HasPrefix(val, "#") { + val = strings.TrimPrefix(val, "#") + hex, err := strconv.ParseUint(val, 16, 32) + if err != nil { + return 0 + } + return vaxis.HexColor(uint32(hex)) + } + return colorNames[val] +} + +func (s *Style) Set(attr, val string) error { + switch attr { + case "fg": + s.Fg = extractColor(val) + case "bg": + s.Bg = extractColor(val) + case "bold": + if state, err := boolSwitch(val, s.Bold); err != nil { + return err + } else { + s.Bold = state + } + case "blink": + if state, err := boolSwitch(val, s.Blink); err != nil { + return err + } else { + s.Blink = state + } + case "underline": + if state, err := boolSwitch(val, s.Underline); err != nil { + return err + } else { + s.Underline = state + } + case "reverse": + if state, err := boolSwitch(val, s.Reverse); err != nil { + return err + } else { + s.Reverse = state + } + case "italic": + if state, err := boolSwitch(val, s.Italic); err != nil { + return err + } else { + s.Italic = state + } + case "dim": + if state, err := boolSwitch(val, s.Dim); err != nil { + return err + } else { + s.Dim = state + } + case "default": + s.Default() + case "normal": + s.Normal() + default: + return errors.New("Unknown style attribute: " + attr) + } + + return nil +} + +func (s Style) composeWith(styles []*Style) Style { + newStyle := s + for _, st := range styles { + if st.Fg != s.Fg && st.Fg != 0 { + newStyle.Fg = st.Fg + } + if st.Bg != s.Bg && st.Bg != 0 { + newStyle.Bg = st.Bg + } + if st.Bold != s.Bold { + newStyle.Bold = st.Bold + } + if st.Blink != s.Blink { + newStyle.Blink = st.Blink + } + if st.Underline != s.Underline { + newStyle.Underline = st.Underline + } + if st.Reverse != s.Reverse { + newStyle.Reverse = st.Reverse + } + if st.Italic != s.Italic { + newStyle.Italic = st.Italic + } + if st.Dim != s.Dim { + newStyle.Dim = st.Dim + } + } + return newStyle +} + +type StyleConf struct { + base Style + dynamic []Style +} + +type StyleSet struct { + objects map[StyleObject]*StyleConf + selected map[StyleObject]*StyleConf + user map[string]*Style + path string +} + +const defaultStyleset string = ` +*.selected.bg = 12 +*.selected.fg = 15 +*.selected.bold = true +statusline_*.dim = true +*warning.dim = false +*warning.bold = true +*warning.fg = 11 +*success.dim = false +*success.bold = true +*success.fg = 10 +*error.dim = false +*error.bold = true +*error.fg = 9 +border.fg = 12 +border.bold = true +title.bg = 12 +title.fg = 15 +title.bold = true +header.fg = 4 +header.bold = true +msglist_unread.bold = true +msglist_deleted.dim = true +msglist_marked.bg = 6 +msglist_marked.fg = 15 +msglist_pill.bg = 12 +msglist_pill.fg = 15 +part_mimetype.fg = 12 +selector_chooser.bold = true +selector_focused.bold = true +selector_focused.bg = 12 +selector_focused.fg = 15 +completion_*.bg = 8 +completion_pill.bg = 12 +completion_default.fg = 15 +completion_description.fg = 15 +completion_description.dim = true +` + +func NewStyleSet() StyleSet { + ss := StyleSet{ + objects: make(map[StyleObject]*StyleConf), + selected: make(map[StyleObject]*StyleConf), + user: make(map[string]*Style), + } + for _, so := range StyleNames { + ss.objects[so] = new(StyleConf) + ss.selected[so] = new(StyleConf) + } + f, err := ini.Load([]byte(defaultStyleset)) + if err == nil { + err = ss.ParseStyleSet(f) + } + if err != nil { + panic(err) + } + return ss +} + +func (c *StyleConf) getStyle(h *mail.Header) *Style { + if h == nil { + return &c.base + } + style := &c.base + + // All dynamic styles must be iterated through, as later ones might be a + // narrower match based due to multiple header patterns. + for _, s := range c.dynamic { + allMatch := true + for header, pattern := range s.headerPatterns { + val, _ := h.Text(header) + allMatch = allMatch && pattern.Re.MatchString(val) + } + + if allMatch { + s := c.base.composeWith([]*Style{&s}) + style = &s + } + } + return style +} + +func (ss StyleSet) Get(so StyleObject, h *mail.Header) vaxis.Style { + return ss.objects[so].getStyle(h).Get() +} + +func (ss StyleSet) Selected(so StyleObject, h *mail.Header) vaxis.Style { + return ss.selected[so].getStyle(h).Get() +} + +func (ss StyleSet) UserStyle(name string) vaxis.Style { + if style, found := ss.user[name]; found { + return style.Get() + } + return vaxis.Style{} +} + +func (ss StyleSet) Compose( + so StyleObject, sos []StyleObject, h *mail.Header, +) vaxis.Style { + base := *ss.objects[so].getStyle(h) + styles := make([]*Style, len(sos)) + for i, so := range sos { + styles[i] = ss.objects[so].getStyle(h) + } + + return base.composeWith(styles).Get() +} + +func (ss StyleSet) ComposeSelected( + so StyleObject, sos []StyleObject, h *mail.Header, +) vaxis.Style { + base := *ss.selected[so].getStyle(h) + styles := make([]*Style, len(sos)) + for i, so := range sos { + styles[i] = ss.selected[so].getStyle(h) + } + + return base.composeWith(styles).Get() +} + +func findStyleSet(stylesetName string, stylesetsDir []string) (string, error) { + for _, dir := range stylesetsDir { + stylesetPath := xdg.ExpandHome(dir, stylesetName) + if _, err := os.Stat(stylesetPath); os.IsNotExist(err) { + continue + } + + return stylesetPath, nil + } + + return "", fmt.Errorf( + "Can't find styleset %q in any of %v", stylesetName, stylesetsDir) +} + +func (ss *StyleSet) ParseStyleSet(file *ini.File) error { + defaultSection, err := file.GetSection(ini.DefaultSection) + if err != nil { + return err + } + + // parse non-selected items first + for _, key := range defaultSection.Keys() { + err = ss.parseKey(key, false) + if err != nil { + return err + } + } + // override with selected items afterwards + for _, key := range defaultSection.Keys() { + err = ss.parseKey(key, true) + if err != nil { + return err + } + } + + user, err := file.GetSection("user") + if err != nil { + // This errors if the section doesn't exist, which is ok + return nil + } + for _, key := range user.KeyStrings() { + tokens := strings.Split(key, ".") + var styleName, attr string + switch len(tokens) { + case 2: + styleName, attr = tokens[0], tokens[1] + default: + return errors.New("Style parsing error: " + key) + } + val := user.KeysHash()[key] + s, ok := ss.user[styleName] + if !ok { + // Haven't seen this name before, add it to the map + s = &Style{} + ss.user[styleName] = s + } + if err := s.Set(attr, val); err != nil { + return fmt.Errorf("[user].%s=%s: %w", key, val, err) + } + } + + return nil +} + +var ( + styleObjRe = regexp.MustCompile(`^([\w\*\?]+)(\.(?:[\w-]+,.+?)+?)?(\.selected)?\.(\w+)$`) + styleHeaderPatternsRe = regexp.MustCompile(`([\w-]+),(~/(?:.+?)/|(?:.+?))\.`) +) + +func (ss *StyleSet) parseKey(key *ini.Key, selected bool) error { + groups := styleObjRe.FindStringSubmatch(key.Name()) + if groups == nil { + return errors.New("invalid style syntax: " + key.Name()) + } + if (groups[3] == ".selected") != selected { + return nil + } + obj, attr := groups[1], groups[4] + + // As there can be multiple header patterns, match them separately, one + // by one + headerMatches := styleHeaderPatternsRe.FindAllStringSubmatch(groups[2]+".", -1) + headerPatterns := make(map[string]*StyleHeaderPattern) + for _, match := range headerMatches { + headerPatterns[match[1]] = &StyleHeaderPattern{ + RawPattern: match[2], + } + } + + objRe, err := fnmatchToRegex(obj) + if err != nil { + return err + } + num := 0 + for sn, so := range StyleNames { + if !objRe.MatchString(sn) { + continue + } + if !selected { + err = ss.objects[so].update(headerPatterns, attr, key.Value()) + if err != nil { + return fmt.Errorf("%s=%s: %w", key.Name(), key.Value(), err) + } + } + err = ss.selected[so].update(headerPatterns, attr, key.Value()) + if err != nil { + return fmt.Errorf("%s=%s: %w", key.Name(), key.Value(), err) + } + num++ + } + if num == 0 { + return errors.New("unknown style object: " + obj) + } + return nil +} + +func (c *StyleConf) update(headerPatterns map[string]*StyleHeaderPattern, attr, val string) error { + if len(headerPatterns) == 0 { + return (&c.base).Set(attr, val) + } + + // Check existing entries and overwrite ones with same header/pattern + for i := range c.dynamic { + s := &c.dynamic[i] + if s.hasSameHeaderPatterns(headerPatterns) { + return s.Set(attr, val) + } + } + + s := Style{} + err := (&s).Set(attr, val) + if err != nil { + return err + } + + for _, p := range headerPatterns { + var pattern string + switch { + case strings.HasPrefix(p.RawPattern, "~/"): + pattern = p.RawPattern[2 : len(p.RawPattern)-1] + case strings.HasPrefix(p.RawPattern, "~"): + pattern = p.RawPattern[1:] + default: + pattern = "^" + regexp.QuoteMeta(p.RawPattern) + "$" + } + + re, err := regexp.Compile(pattern) + if err != nil { + return err + } + p.Re = re + } + + s.headerPatterns = headerPatterns + c.dynamic = append(c.dynamic, s) + return nil +} + +func (ss *StyleSet) LoadStyleSet(stylesetName string, stylesetDirs []string) error { + filepath, err := findStyleSet(stylesetName, stylesetDirs) + if err != nil { + return err + } + + var options ini.LoadOptions + options.SpaceBeforeInlineComment = true + + file, err := ini.LoadSources(options, filepath) + if err != nil { + return err + } + + ss.path = filepath + + return ss.ParseStyleSet(file) +} + +func fnmatchToRegex(pattern string) (*regexp.Regexp, error) { + p := regexp.QuoteMeta(pattern) + p = strings.ReplaceAll(p, `\*`, `.*`) + return regexp.Compile(strings.ReplaceAll(p, `\?`, `.`)) +} + +var colorNames = map[string]vaxis.Color{ + "black": vaxis.IndexColor(0), + "maroon": vaxis.IndexColor(1), + "green": vaxis.IndexColor(2), + "olive": vaxis.IndexColor(3), + "navy": vaxis.IndexColor(4), + "purple": vaxis.IndexColor(5), + "teal": vaxis.IndexColor(6), + "silver": vaxis.IndexColor(7), + "gray": vaxis.IndexColor(8), + "red": vaxis.IndexColor(9), + "lime": vaxis.IndexColor(10), + "yellow": vaxis.IndexColor(11), + "blue": vaxis.IndexColor(12), + "fuchsia": vaxis.IndexColor(13), + "aqua": vaxis.IndexColor(14), + "white": vaxis.IndexColor(15), + "aliceblue": vaxis.HexColor(0xF0F8FF), + "antiquewhite": vaxis.HexColor(0xFAEBD7), + "aquamarine": vaxis.HexColor(0x7FFFD4), + "azure": vaxis.HexColor(0xF0FFFF), + "beige": vaxis.HexColor(0xF5F5DC), + "bisque": vaxis.HexColor(0xFFE4C4), + "blanchedalmond": vaxis.HexColor(0xFFEBCD), + "blueviolet": vaxis.HexColor(0x8A2BE2), + "brown": vaxis.HexColor(0xA52A2A), + "burlywood": vaxis.HexColor(0xDEB887), + "cadetblue": vaxis.HexColor(0x5F9EA0), + "chartreuse": vaxis.HexColor(0x7FFF00), + "chocolate": vaxis.HexColor(0xD2691E), + "coral": vaxis.HexColor(0xFF7F50), + "cornflowerblue": vaxis.HexColor(0x6495ED), + "cornsilk": vaxis.HexColor(0xFFF8DC), + "crimson": vaxis.HexColor(0xDC143C), + "darkblue": vaxis.HexColor(0x00008B), + "darkcyan": vaxis.HexColor(0x008B8B), + "darkgoldenrod": vaxis.HexColor(0xB8860B), + "darkgray": vaxis.HexColor(0xA9A9A9), + "darkgreen": vaxis.HexColor(0x006400), + "darkkhaki": vaxis.HexColor(0xBDB76B), + "darkmagenta": vaxis.HexColor(0x8B008B), + "darkolivegreen": vaxis.HexColor(0x556B2F), + "darkorange": vaxis.HexColor(0xFF8C00), + "darkorchid": vaxis.HexColor(0x9932CC), + "darkred": vaxis.HexColor(0x8B0000), + "darksalmon": vaxis.HexColor(0xE9967A), + "darkseagreen": vaxis.HexColor(0x8FBC8F), + "darkslateblue": vaxis.HexColor(0x483D8B), + "darkslategray": vaxis.HexColor(0x2F4F4F), + "darkturquoise": vaxis.HexColor(0x00CED1), + "darkviolet": vaxis.HexColor(0x9400D3), + "deeppink": vaxis.HexColor(0xFF1493), + "deepskyblue": vaxis.HexColor(0x00BFFF), + "dimgray": vaxis.HexColor(0x696969), + "dodgerblue": vaxis.HexColor(0x1E90FF), + "firebrick": vaxis.HexColor(0xB22222), + "floralwhite": vaxis.HexColor(0xFFFAF0), + "forestgreen": vaxis.HexColor(0x228B22), + "gainsboro": vaxis.HexColor(0xDCDCDC), + "ghostwhite": vaxis.HexColor(0xF8F8FF), + "gold": vaxis.HexColor(0xFFD700), + "goldenrod": vaxis.HexColor(0xDAA520), + "greenyellow": vaxis.HexColor(0xADFF2F), + "honeydew": vaxis.HexColor(0xF0FFF0), + "hotpink": vaxis.HexColor(0xFF69B4), + "indianred": vaxis.HexColor(0xCD5C5C), + "indigo": vaxis.HexColor(0x4B0082), + "ivory": vaxis.HexColor(0xFFFFF0), + "khaki": vaxis.HexColor(0xF0E68C), + "lavender": vaxis.HexColor(0xE6E6FA), + "lavenderblush": vaxis.HexColor(0xFFF0F5), + "lawngreen": vaxis.HexColor(0x7CFC00), + "lemonchiffon": vaxis.HexColor(0xFFFACD), + "lightblue": vaxis.HexColor(0xADD8E6), + "lightcoral": vaxis.HexColor(0xF08080), + "lightcyan": vaxis.HexColor(0xE0FFFF), + "lightgoldenrodyellow": vaxis.HexColor(0xFAFAD2), + "lightgray": vaxis.HexColor(0xD3D3D3), + "lightgreen": vaxis.HexColor(0x90EE90), + "lightpink": vaxis.HexColor(0xFFB6C1), + "lightsalmon": vaxis.HexColor(0xFFA07A), + "lightseagreen": vaxis.HexColor(0x20B2AA), + "lightskyblue": vaxis.HexColor(0x87CEFA), + "lightslategray": vaxis.HexColor(0x778899), + "lightsteelblue": vaxis.HexColor(0xB0C4DE), + "lightyellow": vaxis.HexColor(0xFFFFE0), + "limegreen": vaxis.HexColor(0x32CD32), + "linen": vaxis.HexColor(0xFAF0E6), + "mediumaquamarine": vaxis.HexColor(0x66CDAA), + "mediumblue": vaxis.HexColor(0x0000CD), + "mediumorchid": vaxis.HexColor(0xBA55D3), + "mediumpurple": vaxis.HexColor(0x9370DB), + "mediumseagreen": vaxis.HexColor(0x3CB371), + "mediumslateblue": vaxis.HexColor(0x7B68EE), + "mediumspringgreen": vaxis.HexColor(0x00FA9A), + "mediumturquoise": vaxis.HexColor(0x48D1CC), + "mediumvioletred": vaxis.HexColor(0xC71585), + "midnightblue": vaxis.HexColor(0x191970), + "mintcream": vaxis.HexColor(0xF5FFFA), + "mistyrose": vaxis.HexColor(0xFFE4E1), + "moccasin": vaxis.HexColor(0xFFE4B5), + "navajowhite": vaxis.HexColor(0xFFDEAD), + "oldlace": vaxis.HexColor(0xFDF5E6), + "olivedrab": vaxis.HexColor(0x6B8E23), + "orange": vaxis.HexColor(0xFFA500), + "orangered": vaxis.HexColor(0xFF4500), + "orchid": vaxis.HexColor(0xDA70D6), + "palegoldenrod": vaxis.HexColor(0xEEE8AA), + "palegreen": vaxis.HexColor(0x98FB98), + "paleturquoise": vaxis.HexColor(0xAFEEEE), + "palevioletred": vaxis.HexColor(0xDB7093), + "papayawhip": vaxis.HexColor(0xFFEFD5), + "peachpuff": vaxis.HexColor(0xFFDAB9), + "peru": vaxis.HexColor(0xCD853F), + "pink": vaxis.HexColor(0xFFC0CB), + "plum": vaxis.HexColor(0xDDA0DD), + "powderblue": vaxis.HexColor(0xB0E0E6), + "rebeccapurple": vaxis.HexColor(0x663399), + "rosybrown": vaxis.HexColor(0xBC8F8F), + "royalblue": vaxis.HexColor(0x4169E1), + "saddlebrown": vaxis.HexColor(0x8B4513), + "salmon": vaxis.HexColor(0xFA8072), + "sandybrown": vaxis.HexColor(0xF4A460), + "seagreen": vaxis.HexColor(0x2E8B57), + "seashell": vaxis.HexColor(0xFFF5EE), + "sienna": vaxis.HexColor(0xA0522D), + "skyblue": vaxis.HexColor(0x87CEEB), + "slateblue": vaxis.HexColor(0x6A5ACD), + "slategray": vaxis.HexColor(0x708090), + "snow": vaxis.HexColor(0xFFFAFA), + "springgreen": vaxis.HexColor(0x00FF7F), + "steelblue": vaxis.HexColor(0x4682B4), + "tan": vaxis.HexColor(0xD2B48C), + "thistle": vaxis.HexColor(0xD8BFD8), + "tomato": vaxis.HexColor(0xFF6347), + "turquoise": vaxis.HexColor(0x40E0D0), + "violet": vaxis.HexColor(0xEE82EE), + "wheat": vaxis.HexColor(0xF5DEB3), + "whitesmoke": vaxis.HexColor(0xF5F5F5), + "yellowgreen": vaxis.HexColor(0x9ACD32), +} diff --git a/config/style_test.go b/config/style_test.go new file mode 100644 index 0000000..64d723e --- /dev/null +++ b/config/style_test.go @@ -0,0 +1,101 @@ +package config + +import ( + "testing" + + "github.com/emersion/go-message/mail" + "github.com/go-ini/ini" +) + +const multiHeaderStyleset string = ` +msglist_*.fg = salmon +msglist_*.From,~^"Bob Foo".fg = khaki +msglist_*.From,~^"Bob Foo".selected.fg = palegreen +msglist_*.Subject,~PATCH.From,~^"Bob Foo".fg = coral +msglist_*.From,~^"Bob Foo".Subject,~PATCH.X-Baz,exact.X-Clacks-Overhead,~Pratchett$.fg = plum +msglist_*.From,~^"Bob Foo".Subject,~PATCH.X-Clacks-Overhead,~Pratchett$.fg = pink +msglist_*.From,~^"Bob Foo".List-ID,~/lists\.sr\.ht/.fg = pink +` + +func TestStyleMultiHeaderPattern(t *testing.T) { + ini, err := ini.Load([]byte(multiHeaderStyleset)) + if err != nil { + t.Errorf("failed to load styleset: %v", err) + } + + ss := NewStyleSet() + err = ss.ParseStyleSet(ini) + if err != nil { + t.Errorf("failed to parse styleset: %v", err) + } + + t.Run("default color", func(t *testing.T) { + var h mail.Header + h.SetAddressList("From", []*mail.Address{{"Alice Foo", "alice@foo.org"}}) + + s := ss.Get(STYLE_MSGLIST_DEFAULT, &h) + if s.Foreground != colorNames["salmon"] { + t.Errorf("expected:#%v got:#%v", colorNames["salmon"], s.Foreground) + } + }) + + t.Run("single header", func(t *testing.T) { + var h mail.Header + h.SetAddressList("From", []*mail.Address{{"Bob Foo", "bob@foo.org"}}) + + s := ss.Get(STYLE_MSGLIST_DEFAULT, &h) + if s.Foreground != colorNames["khaki"] { + t.Errorf("expected:#%v got:#%v", colorNames["khaki"], s.Foreground) + } + }) + + t.Run("two headers", func(t *testing.T) { + var h mail.Header + h.SetAddressList("From", []*mail.Address{{"Bob Foo", "Bob@foo.org"}}) + h.SetSubject("[PATCH] tests") + + s := ss.Get(STYLE_MSGLIST_DEFAULT, &h) + if s.Foreground != colorNames["coral"] { + t.Errorf("expected:#%x got:#%x", colorNames["coral"], s.Foreground) + } + }) + + t.Run("multiple headers", func(t *testing.T) { + var h mail.Header + h.SetAddressList("From", []*mail.Address{{"Bob Foo", "Bob@foo.org"}}) + h.SetSubject("[PATCH] tests") + h.SetText("X-Clacks-Overhead", "GNU Terry Pratchett") + + s := ss.Get(STYLE_MSGLIST_DEFAULT, &h) + if s.Foreground != colorNames["pink"] { + t.Errorf("expected:#%x got:#%x", colorNames["pink"], s.Foreground) + } + }) + + t.Run("preserves order-sensitivity", func(t *testing.T) { + var h mail.Header + h.SetAddressList("From", []*mail.Address{{"Bob Foo", "Bob@foo.org"}}) + h.SetSubject("[PATCH] tests") + h.SetText("X-Clacks-Overhead", "GNU Terry Pratchett") + h.SetText("X-Baz", "exact") + + s := ss.Get(STYLE_MSGLIST_DEFAULT, &h) + + // The "pink" entry comes later, so will overrule the more exact + // match with color "plum" + if s.Foreground != colorNames["pink"] { + t.Errorf("expected:#%x got:#%x", colorNames["pink"], s.Foreground) + } + }) + + t.Run("handles uris in regular expressions", func(t *testing.T) { + var h mail.Header + h.SetAddressList("From", []*mail.Address{{"Bob Foo", "Bob@foo.org"}}) + h.SetText("List-ID", "List-ID: ~rjarry/aerc-discuss <~rjarry/aerc-discuss.lists.sr.ht>") + + s := ss.Get(STYLE_MSGLIST_DEFAULT, &h) + if s.Foreground != colorNames["pink"] { + t.Errorf("expected:#%x got:#%x", colorNames["pink"], s.Foreground) + } + }) +} diff --git a/config/templates.go b/config/templates.go new file mode 100644 index 0000000..fd7f018 --- /dev/null +++ b/config/templates.go @@ -0,0 +1,125 @@ +package config + +import ( + "path" + "time" + + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/templates" + "git.sr.ht/~rjarry/aerc/models" + "github.com/emersion/go-message/mail" + "github.com/go-ini/ini" +) + +type TemplateConfig struct { + TemplateDirs []string `ini:"template-dirs" delim:":"` + NewMessage string `ini:"new-message" default:"new_message"` + QuotedReply string `ini:"quoted-reply" default:"quoted_reply"` + Forwards string `ini:"forwards" default:"forward_as_body"` +} + +var Templates = new(TemplateConfig) + +func parseTemplates(file *ini.File) error { + if err := MapToStruct(file.Section("templates"), Templates, true); err != nil { + return err + } + + // append default paths to template-dirs + for _, dir := range SearchDirs { + Templates.TemplateDirs = append( + Templates.TemplateDirs, path.Join(dir, "templates"), + ) + } + + // we want to fail during startup if the templates are not ok + // hence we do dummy executes here + t := Templates + if err := checkTemplate(t.NewMessage, t.TemplateDirs); err != nil { + return err + } + if err := checkTemplate(t.QuotedReply, t.TemplateDirs); err != nil { + return err + } + if err := checkTemplate(t.Forwards, t.TemplateDirs); err != nil { + return err + } + + log.Debugf("aerc.conf: [templates] %#v", Templates) + + return nil +} + +func checkTemplate(filename string, dirs []string) error { + var data dummyData + _, err := templates.ParseTemplateFromFile(filename, dirs, &data) + return err +} + +// only for validation +type dummyData struct{} + +var ( + addr1 = mail.Address{Name: "John Foo", Address: "foo@bar.org"} + addr2 = mail.Address{Name: "John Bar", Address: "bar@foo.org"} +) + +func (d *dummyData) Account() string { return "work" } +func (d *dummyData) AccountBackend() string { return "maildir" } +func (d *dummyData) AccountFrom() *mail.Address { return &addr1 } +func (d *dummyData) Signature() string { return "" } +func (d *dummyData) Folder() string { return "INBOX" } +func (d *dummyData) To() []*mail.Address { return []*mail.Address{&addr1} } +func (d *dummyData) Cc() []*mail.Address { return nil } +func (d *dummyData) Bcc() []*mail.Address { return nil } +func (d *dummyData) From() []*mail.Address { return []*mail.Address{&addr2} } +func (d *dummyData) Peer() []*mail.Address { return d.From() } +func (d *dummyData) ReplyTo() []*mail.Address { return nil } +func (d *dummyData) Date() time.Time { return time.Now() } +func (d *dummyData) DateAutoFormat(time.Time) string { return "" } +func (d *dummyData) Header(string) string { return "" } +func (d *dummyData) ThreadPrefix() string { return "└─>" } +func (d *dummyData) ThreadCount() int { return 0 } +func (d *dummyData) ThreadUnread() int { return 0 } +func (d *dummyData) ThreadFolded() bool { return false } +func (d *dummyData) ThreadContext() bool { return true } +func (d *dummyData) ThreadOrphan() bool { return true } +func (d *dummyData) Subject() string { return "Re: [PATCH] hey" } +func (d *dummyData) SubjectBase() string { return "[PATCH] hey" } +func (d *dummyData) Attach(string) string { return "" } +func (d *dummyData) Number() int { return 0 } +func (d *dummyData) Labels() []string { return nil } +func (d *dummyData) Filename() string { return "" } +func (d *dummyData) Filenames() []string { return nil } +func (d *dummyData) Flags() []string { return nil } +func (d *dummyData) IsReplied() bool { return true } +func (d *dummyData) HasAttachment() bool { return true } +func (d *dummyData) IsRecent() bool { return false } +func (d *dummyData) IsUnread() bool { return false } +func (d *dummyData) IsFlagged() bool { return false } +func (d *dummyData) IsDraft() bool { return false } +func (d *dummyData) IsMarked() bool { return false } +func (d *dummyData) IsForwarded() bool { return false } +func (d *dummyData) MessageId() string { return "123456789@foo.org" } +func (d *dummyData) Size() int { return 420 } +func (d *dummyData) OriginalText() string { return "Blah blah blah" } +func (d *dummyData) OriginalDate() time.Time { return time.Now() } +func (d *dummyData) OriginalFrom() []*mail.Address { return d.From() } +func (d *dummyData) OriginalMIMEType() string { return "text/plain" } +func (d *dummyData) OriginalHeader(string) string { return "" } +func (d *dummyData) Recent(...string) int { return 1 } +func (d *dummyData) Unread(...string) int { return 3 } +func (d *dummyData) Exists(...string) int { return 14 } +func (d *dummyData) RUE(...string) string { return "1/3/14" } +func (d *dummyData) Connected() bool { return false } +func (d *dummyData) ConnectionInfo() string { return "" } +func (d *dummyData) ContentInfo() string { return "" } +func (d *dummyData) StatusInfo() string { return "" } +func (d *dummyData) TrayInfo() string { return "" } +func (d *dummyData) PendingKeys() string { return "" } +func (d *dummyData) Role() string { return "inbox" } + +func (d *dummyData) Style(string, string) string { return "" } +func (d *dummyData) StyleSwitch(string, ...models.Case) string { return "" } + +func (d *dummyData) StyleMap([]string, ...models.Case) []string { return []string{} } diff --git a/config/ui.go b/config/ui.go new file mode 100644 index 0000000..bd8eb4a --- /dev/null +++ b/config/ui.go @@ -0,0 +1,430 @@ +package config + +import ( + "fmt" + "math" + "path" + "regexp" + "strconv" + "text/template" + "time" + + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rockorager/vaxis" + "github.com/emersion/go-message/mail" + "github.com/go-ini/ini" +) + +type UIConfig struct { + IndexColumns []*ColumnDef `ini:"index-columns" parse:"ParseIndexColumns" default:"flags:4,name<20%,subject,date>="` + ColumnSeparator string `ini:"column-separator" default:" "` + + DirListLeft *template.Template `ini:"dirlist-left" default:"{{.Folder}}"` + DirListRight *template.Template `ini:"dirlist-right" default:"{{if .Unread}}{{humanReadable .Unread}}{{end}}"` + + AutoMarkRead bool `ini:"auto-mark-read" default:"true"` + TimestampFormat string `ini:"timestamp-format" default:"2006 Jan 02"` + ThisDayTimeFormat string `ini:"this-day-time-format" default:"15:04"` + ThisWeekTimeFormat string `ini:"this-week-time-format" default:"Jan 02"` + ThisYearTimeFormat string `ini:"this-year-time-format" default:"Jan 02"` + MessageViewTimestampFormat string `ini:"message-view-timestamp-format" default:"2006 Jan 02, 15:04 GMT-0700"` + MessageViewThisDayTimeFormat string `ini:"message-view-this-day-time-format"` + MessageViewThisWeekTimeFormat string `ini:"message-view-this-week-time-format"` + MessageViewThisYearTimeFormat string `ini:"message-view-this-year-time-format"` + PinnedTabMarker string "ini:\"pinned-tab-marker\" default:\"`\"" + SidebarWidth int `ini:"sidebar-width" default:"22"` + QuakeHeight int `ini:"quake-terminal-height" default:"20"` + MessageListSplit SplitParams `ini:"message-list-split" parse:"ParseSplit"` + EmptyMessage string `ini:"empty-message" default:"(no messages)"` + EmptyDirlist string `ini:"empty-dirlist" default:"(no folders)"` + EmptySubject string `ini:"empty-subject" default:"(no subject)"` + MouseEnabled bool `ini:"mouse-enabled"` + ThreadingEnabled bool `ini:"threading-enabled"` + ForceClientThreads bool `ini:"force-client-threads"` + ThreadingBySubject bool `ini:"threading-by-subject"` + ClientThreadsDelay time.Duration `ini:"client-threads-delay" default:"50ms"` + ThreadContext bool `ini:"show-thread-context"` + FuzzyComplete bool `ini:"fuzzy-complete"` + NewMessageBell bool `ini:"new-message-bell" default:"true"` + Spinner string `ini:"spinner" default:"[..] , [..] , [..] , [..] , [..], [..] , [..] , [..] "` + SpinnerDelimiter string `ini:"spinner-delimiter" default:","` + SpinnerInterval time.Duration `ini:"spinner-interval" default:"200ms"` + IconUnencrypted string `ini:"icon-unencrypted"` + IconEncrypted string `ini:"icon-encrypted" default:"[e]"` + IconSigned string `ini:"icon-signed" default:"[s]"` + IconSignedEncrypted string `ini:"icon-signed-encrypted"` + IconUnknown string `ini:"icon-unknown" default:"[s?]"` + IconInvalid string `ini:"icon-invalid" default:"[s!]"` + IconAttachment string `ini:"icon-attachment" default:"a"` + IconReplied string `ini:"icon-replied" default:"r"` + IconForwarded string `ini:"icon-forwarded" default:"f"` + IconNew string `ini:"icon-new" default:"N"` + IconOld string `ini:"icon-old" default:"O"` + IconDraft string `ini:"icon-draft" default:"d"` + IconFlagged string `ini:"icon-flagged" default:"!"` + IconMarked string `ini:"icon-marked" default:"*"` + IconDeleted string `ini:"icon-deleted" default:"X"` + DirListDelay time.Duration `ini:"dirlist-delay" default:"200ms"` + DirListTree bool `ini:"dirlist-tree"` + DirListCollapse int `ini:"dirlist-collapse"` + Sort []string `ini:"sort" delim:" "` + NextMessageOnDelete bool `ini:"next-message-on-delete" default:"true"` + CompletionDelay time.Duration `ini:"completion-delay" default:"250ms"` + CompletionMinChars int `ini:"completion-min-chars" default:"1" parse:"ParseCompletionMinChars"` + CompletionPopovers bool `ini:"completion-popovers" default:"true"` + MsglistScrollOffset int `ini:"msglist-scroll-offset" default:"0"` + DialogPosition string `ini:"dialog-position" default:"center" parse:"ParseDialogPosition"` + DialogWidth int `ini:"dialog-width" default:"50" parse:"ParseDialogDimensions"` + DialogHeight int `ini:"dialog-height" default:"50" parse:"ParseDialogDimensions"` + StyleSetDirs []string `ini:"stylesets-dirs" delim:":"` + StyleSetName string `ini:"styleset-name" default:"default"` + style StyleSet + // customize border appearance + BorderCharVertical rune `ini:"border-char-vertical" default:"│" type:"rune"` + BorderCharHorizontal rune `ini:"border-char-horizontal" default:"─" type:"rune"` + + SelectLast bool `ini:"select-last-message" default:"false"` + ReverseOrder bool `ini:"reverse-msglist-order"` + ReverseThreadOrder bool `ini:"reverse-thread-order"` + SortThreadSiblings bool `ini:"sort-thread-siblings"` + + ThreadPrefixTip string `ini:"thread-prefix-tip" default:">"` + ThreadPrefixIndent string `ini:"thread-prefix-indent" default:" "` + ThreadPrefixStem string `ini:"thread-prefix-stem" default:"│"` + ThreadPrefixLimb string `ini:"thread-prefix-limb" default:""` + ThreadPrefixFolded string `ini:"thread-prefix-folded" default:"+"` + ThreadPrefixUnfolded string `ini:"thread-prefix-unfolded" default:""` + ThreadPrefixFirstChild string `ini:"thread-prefix-first-child" default:""` + ThreadPrefixHasSiblings string `ini:"thread-prefix-has-siblings" default:"├─"` + ThreadPrefixLone string `ini:"thread-prefix-lone" default:""` + ThreadPrefixOrphan string `ini:"thread-prefix-orphan" default:""` + ThreadPrefixLastSibling string `ini:"thread-prefix-last-sibling" default:"└─"` + ThreadPrefixDummy string `ini:"thread-prefix-dummy" default:"┬─"` + ThreadPrefixLastSiblingReverse string `ini:"thread-prefix-last-sibling-reverse" default:"┌─"` + ThreadPrefixFirstChildReverse string `ini:"thread-prefix-first-child-reverse" default:""` + ThreadPrefixOrphanReverse string `ini:"thread-prefix-orphan-reverse" default:""` + ThreadPrefixDummyReverse string `ini:"thread-prefix-dummy-reverse" default:"┴─"` + + // Tab Templates + TabTitleAccount *template.Template `ini:"tab-title-account" default:"{{.Account}}"` + TabTitleComposer *template.Template `ini:"tab-title-composer" default:"{{if .To}}to:{{index (.To | shortmboxes) 0}} {{end}}{{.SubjectBase}}"` + TabTitleViewer *template.Template `ini:"tab-title-viewer" default:"{{.Subject}}"` + + // private + contextualUis []*UiConfigContext + contextualCounts map[uiContextType]int + contextualCache map[uiContextKey]*UIConfig +} + +type uiContextType int + +const ( + uiContextFolder uiContextType = iota + uiContextAccount +) + +type UiConfigContext struct { + ContextType uiContextType + Regex *regexp.Regexp + UiConfig *UIConfig + Section ini.Section +} + +type uiContextKey struct { + ctxType uiContextType + value string +} + +var Ui = defaultUIConfig() + +func defaultUIConfig() *UIConfig { + return &UIConfig{ + contextualCounts: make(map[uiContextType]int), + contextualCache: make(map[uiContextKey]*UIConfig), + } +} + +var uiContextualSectionRe = regexp.MustCompile(`^ui:(account|folder|subject)([~=])(.+)$`) + +func parseUi(file *ini.File) error { + if err := Ui.parse(file.Section("ui")); err != nil { + return err + } + + for _, section := range file.Sections() { + var err error + groups := uiContextualSectionRe.FindStringSubmatch(section.Name()) + if groups == nil { + continue + } + ctx, separator, value := groups[1], groups[2], groups[3] + + uiSubConfig := UIConfig{} + if err = uiSubConfig.parse(section); err != nil { + return err + } + contextualUi := UiConfigContext{ + UiConfig: &uiSubConfig, + Section: *section, + } + + switch ctx { + case "account": + contextualUi.ContextType = uiContextAccount + case "folder": + contextualUi.ContextType = uiContextFolder + } + if separator == "=" { + value = "^" + regexp.QuoteMeta(value) + "$" + } + contextualUi.Regex, err = regexp.Compile(value) + if err != nil { + return err + } + + Ui.contextualUis = append(Ui.contextualUis, &contextualUi) + Ui.contextualCounts[contextualUi.ContextType]++ + } + + // append default paths to styleset-dirs + for _, dir := range SearchDirs { + Ui.StyleSetDirs = append( + Ui.StyleSetDirs, path.Join(dir, "stylesets"), + ) + } + + if err := Ui.LoadStyle(); err != nil { + return err + } + + log.Debugf("aerc.conf: [ui] %#v", Ui) + + return nil +} + +func (config *UIConfig) parse(section *ini.Section) error { + if err := MapToStruct(section, config, section.Name() == "ui"); err != nil { + return err + } + + if config.MessageViewTimestampFormat == "" { + config.MessageViewTimestampFormat = config.TimestampFormat + } + + return nil +} + +func (*UIConfig) ParseIndexColumns(section *ini.Section, key *ini.Key) ([]*ColumnDef, error) { + if !section.HasKey("column-date") { + _, _ = section.NewKey("column-date", `{{.DateAutoFormat .Date.Local}}`) + } + if !section.HasKey("column-name") { + _, _ = section.NewKey("column-name", `{{index (.From | names) 0}}`) + } + if !section.HasKey("column-flags") { + _, _ = section.NewKey("column-flags", `{{.Flags | join ""}}`) + } + if !section.HasKey("column-subject") { + _, _ = section.NewKey("column-subject", `{{.ThreadPrefix}}{{.Subject}}`) + } + return ParseColumnDefs(key, section) +} + +type SplitDirection int + +const ( + SPLIT_NONE SplitDirection = iota + SPLIT_HORIZONTAL + SPLIT_VERTICAL +) + +type SplitParams struct { + Direction SplitDirection + Size int +} + +func (*UIConfig) ParseSplit(section *ini.Section, key *ini.Key) (p SplitParams, err error) { + re := regexp.MustCompile(`^\s*(v(?:ert(?:ical)?)?|h(?:oriz(?:ontal)?)?)?\s+(\d+)\s*$`) + match := re.FindStringSubmatch(key.String()) + if len(match) != 3 { + err = fmt.Errorf("bad option value") + return + } + p.Direction = SPLIT_HORIZONTAL + switch match[1] { + case "v", "vert", "vertical": + p.Direction = SPLIT_VERTICAL + case "h", "horiz", "horizontal": + p.Direction = SPLIT_HORIZONTAL + } + size, e := strconv.ParseUint(match[2], 10, 32) + if e != nil { + err = e + return + } + p.Size = int(size) + return +} + +func (*UIConfig) ParseDialogPosition(section *ini.Section, key *ini.Key) (string, error) { + match, _ := regexp.MatchString(`^\s*(top|center|bottom)\s*$`, key.String()) + if !(match) { + return "", fmt.Errorf("bad option value") + } + return key.String(), nil +} + +const ( + DIALOG_MIN_PROPORTION = 10 + DIALOG_MAX_PROPORTION = 100 +) + +func (*UIConfig) ParseDialogDimensions(section *ini.Section, key *ini.Key) (int, error) { + value, err := key.Int() + if value < DIALOG_MIN_PROPORTION || value > DIALOG_MAX_PROPORTION || err != nil { + return 0, fmt.Errorf("value out of range") + } + return value, nil +} + +const MANUAL_COMPLETE = math.MaxInt + +func (*UIConfig) ParseCompletionMinChars(section *ini.Section, key *ini.Key) (int, error) { + if key.String() == "manual" { + return MANUAL_COMPLETE, nil + } + return key.Int() +} + +func (ui *UIConfig) ClearCache() { + for k := range ui.contextualCache { + delete(ui.contextualCache, k) + } +} + +func (ui *UIConfig) LoadStyle() error { + if err := ui.loadStyleSet(ui.StyleSetDirs); err != nil { + return err + } + + for _, contextualUi := range ui.contextualUis { + if contextualUi.UiConfig.StyleSetName == "" && + len(contextualUi.UiConfig.StyleSetDirs) == 0 { + continue // no need to do anything if nothing is overridden + } + // fill in the missing part from the base + if contextualUi.UiConfig.StyleSetName == "" { + contextualUi.UiConfig.StyleSetName = ui.StyleSetName + } else if len(contextualUi.UiConfig.StyleSetDirs) == 0 { + contextualUi.UiConfig.StyleSetDirs = ui.StyleSetDirs + } + // since at least one of them has changed, load the styleset + if err := contextualUi.UiConfig.loadStyleSet( + contextualUi.UiConfig.StyleSetDirs); err != nil { + return err + } + } + + return nil +} + +func (ui *UIConfig) loadStyleSet(styleSetDirs []string) error { + ui.style = NewStyleSet() + err := ui.style.LoadStyleSet(ui.StyleSetName, styleSetDirs) + if err != nil { + if ui.style.path == "" { + ui.style.path = ui.StyleSetName + } + return fmt.Errorf("%s: %w", ui.style.path, err) + } + + return nil +} + +func (base *UIConfig) mergeContextual( + contextType uiContextType, s string, +) *UIConfig { + for _, contextualUi := range base.contextualUis { + if contextualUi.ContextType != contextType { + continue + } + if !contextualUi.Regex.Match([]byte(s)) { + continue + } + ui := *base + err := ui.parse(&contextualUi.Section) + if err != nil { + log.Warnf("merge ui failed: %v", err) + } + ui.contextualCache = make(map[uiContextKey]*UIConfig) + ui.contextualCounts = base.contextualCounts + ui.contextualUis = base.contextualUis + if contextualUi.UiConfig.StyleSetName != "" { + ui.style = contextualUi.UiConfig.style + } + return &ui + } + return base +} + +func (uiConfig *UIConfig) GetUserStyle(name string) vaxis.Style { + return uiConfig.style.UserStyle(name) +} + +func (uiConfig *UIConfig) GetStyle(so StyleObject) vaxis.Style { + return uiConfig.style.Get(so, nil) +} + +func (uiConfig *UIConfig) GetStyleSelected(so StyleObject) vaxis.Style { + return uiConfig.style.Selected(so, nil) +} + +func (uiConfig *UIConfig) GetComposedStyle(base StyleObject, + styles []StyleObject, +) vaxis.Style { + return uiConfig.style.Compose(base, styles, nil) +} + +func (uiConfig *UIConfig) GetComposedStyleSelected( + base StyleObject, styles []StyleObject, +) vaxis.Style { + return uiConfig.style.ComposeSelected(base, styles, nil) +} + +func (uiConfig *UIConfig) MsgComposedStyle( + base StyleObject, styles []StyleObject, h *mail.Header, +) vaxis.Style { + return uiConfig.style.Compose(base, styles, h) +} + +func (uiConfig *UIConfig) MsgComposedStyleSelected( + base StyleObject, styles []StyleObject, h *mail.Header, +) vaxis.Style { + return uiConfig.style.ComposeSelected(base, styles, h) +} + +func (uiConfig *UIConfig) StyleSetPath() string { + return uiConfig.style.path +} + +func (base *UIConfig) contextual(ctxType uiContextType, value string) *UIConfig { + if base.contextualCounts[ctxType] == 0 { + // shortcut if no contextual ui for that type + return base + } + key := uiContextKey{ctxType: ctxType, value: value} + c, found := base.contextualCache[key] + if !found { + c = base.mergeContextual(ctxType, value) + base.contextualCache[key] = c + } + return c +} + +func (base *UIConfig) ForAccount(account string) *UIConfig { + return base.contextual(uiContextAccount, account) +} + +func (base *UIConfig) ForFolder(folder string) *UIConfig { + return base.contextual(uiContextFolder, folder) +} diff --git a/config/viewer.go b/config/viewer.go new file mode 100644 index 0000000..634a3a0 --- /dev/null +++ b/config/viewer.go @@ -0,0 +1,32 @@ +package config + +import ( + "git.sr.ht/~rjarry/aerc/lib/log" + "github.com/go-ini/ini" +) + +type ViewerConfig struct { + Pager string `ini:"pager" default:"less -Rc"` + Alternatives []string `ini:"alternatives" default:"text/plain,text/html" delim:","` + ShowHeaders bool `ini:"show-headers"` + AlwaysShowMime bool `ini:"always-show-mime"` + MaxMimeHeight int `ini:"max-mime-height" default:"0"` + ParseHttpLinks bool `ini:"parse-http-links" default:"true"` + HeaderLayout [][]string `ini:"header-layout" parse:"ParseLayout" default:"From|To,Cc|Bcc,Date,Subject"` + KeyPassthrough bool +} + +var Viewer = new(ViewerConfig) + +func parseViewer(file *ini.File) error { + if err := MapToStruct(file.Section("viewer"), Viewer, true); err != nil { + return err + } + log.Debugf("aerc.conf: [viewer] %#v", Viewer) + return nil +} + +func (v *ViewerConfig) ParseLayout(sec *ini.Section, key *ini.Key) ([][]string, error) { + layout := parseLayout(key.String()) + return layout, nil +} diff --git a/contrib/aerc.desktop b/contrib/aerc.desktop new file mode 100644 index 0000000..eb521f2 --- /dev/null +++ b/contrib/aerc.desktop @@ -0,0 +1,16 @@ +[Desktop Entry] +Version=1.0 +Name=aerc + +GenericName=Mail Client +GenericName[de]=Email Client +Comment=Launches the aerc email client +Comment[de]=Startet den aerc Email-Client +Keywords=Email,Mail,IMAP,SMTP +Categories=Office;Network;Email;ConsoleOnly + +Type=Application +Icon=utilities-terminal +Terminal=true +Exec=aerc %u +MimeType=x-scheme-handler/mailto diff --git a/contrib/carddav-query b/contrib/carddav-query new file mode 100755 index 0000000..58c5c9c --- /dev/null +++ b/contrib/carddav-query @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Copyright (c) 2023 Robin Jarry + +""" +Query a CardDAV server for contact names and emails. +""" + +import argparse +import base64 +import configparser +import os +import re +import subprocess +import sys +import xml.etree.ElementTree as xml +from urllib import error, parse, request + + +def main(): + try: + args = parse_args() + + C = "urn:ietf:params:xml:ns:carddav" + D = "DAV:" + xml.register_namespace("C", C) + xml.register_namespace("D", D) + + # perform the actual address book query + query = xml.Element(f"{{{C}}}addressbook-query") + prop = xml.SubElement(query, f"{{{D}}}prop") + xml.SubElement(prop, f"{{{D}}}getetag") + data = xml.SubElement(prop, f"{{{C}}}address-data") + xml.SubElement(data, f"{{{C}}}prop", name="FN") + xml.SubElement(data, f"{{{C}}}prop", name="EMAIL") + limit = xml.SubElement(query, f"{{{C}}}limit") + xml.SubElement(limit, f"{{{C}}}nresults").text = str(args.limit) + filtre = xml.SubElement(query, f"{{{C}}}filter", test="anyof") + for term in args.terms: + for attr in "FN", "EMAIL", "NICKNAME", "ORG", "TITLE": + prop = xml.SubElement(filtre, f"{{{C}}}prop-filter", name=attr) + match = xml.SubElement( + prop, f"{{{C}}}text-match", {"match-type": "contains"} + ) + match.text = term + data = http_request_xml( + "REPORT", + args.server_url, + query, + username=args.username, + password=args.password, + debug=args.verbose, + Depth="1", + ) + for vcard in data.iterfind(f".//{{{C}}}address-data"): + for name, email in parse_vcard(vcard.text.strip()): + print(f"{email}\t{name}") + + except Exception as e: + if isinstance(e, error.HTTPError): + if args.verbose: + debug_response(e.fp) + e = e.fp.read().decode() + print(f"error: {e}", file=sys.stderr) + sys.exit(1) + + +def http_request_xml( + method: str, + url: str, + data: xml.Element, + username: str = None, + password: str = None, + debug: bool = False, + **headers, +) -> xml.Element: + req = request.Request( + url=url, + method=method, + headers={ + "Content-Type": 'text/xml; charset="utf-8"', + **headers, + }, + data=xml.tostring(data, encoding="utf-8", xml_declaration=True), + ) + if username is not None and password is not None: + auth = f"{username}:{password}" + auth = base64.standard_b64encode(auth.encode("utf-8")).decode("ascii") + req.add_header("Authorization", f"Basic {auth}") + + if debug: + uri = parse.urlparse(req.full_url) + print(f"> {req.method} {uri.path} HTTP/1.1", file=sys.stderr) + print(f"> Host: {uri.hostname}", file=sys.stderr) + for name, value in req.headers.items(): + print(f"> {name}: {value}", file=sys.stderr) + print(f"{req.data.decode('utf-8')}\n", file=sys.stderr) + + with request.urlopen(req) as resp: + data = resp.read().decode("utf-8") + if debug: + debug_response(resp) + print(f"{data}", file=sys.stderr) + + return xml.fromstring(data) + + +def debug_response(resp): + print(f"< HTTP/1.1 {resp.code}", file=sys.stderr) + for name, value in resp.headers.items(): + print(f"< {name}: {value}", file=sys.stderr) + + +def parse_vcard(txt): + lines = txt.splitlines() + if len(lines) < 4 or lines[0] != "BEGIN:VCARD" or lines[-1] != "END:VCARD": + return + name = None + emails = [] + for line in lines[1:-1]: + if line.startswith("FN:"): + name = line[len("FN:") :].replace("\\,", ",") + continue + match = re.match(r"^(?:ITEM\d+\.)?EMAIL(?:;[\w-]+=[^;:]+)*:(.+@.+)$", line) + if match: + email = match.group(1).lower().replace("\\,", ",") + if email not in emails: + if "TYPE=pref" in line or "PREF=1" in line: + emails.insert(0, email) + else: + emails.append(email) + if name is not None: + for e in emails: + yield name, e + + +def parse_args(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "-l", + "--limit", + default=10, + type=int, + help=""" + Maximum number of results returned by the server (default: 10). + If the server does not support limiting, this will be disregarded. + """, + ) + parser.add_argument( + "-v", + "--verbose", + action="store_true", + help=""" + Print debug info on stderr. + """, + ) + parser.add_argument( + "-c", + "--config-file", + metavar="FILE", + default=os.path.expanduser("~/.config/aerc/accounts.conf"), + help=""" + INI configuration file from which to read the CardDAV URL endpoint + (default: ~/.config/aerc/accounts.conf). + """, + ) + parser.add_argument( + "-S", + "--config-section", + metavar="SECTION", + help=""" + INI configuration section where to find CONFIG_KEY. By default the + first section where CONFIG_KEY is found will be used. + """, + ) + parser.add_argument( + "-k", + "--config-key-source", + metavar="KEY_SOURCE", + default="carddav-source", + help=""" + INI configuration key to lookup in CONFIG_SECTION from CONFIG_FILE. + The value must respect the following format: + https?://USERNAME[:PASSWORD]@HOSTNAME/PATH/TO/ADDRESSBOOK. + Both USERNAME and PASSWORD must be percent encoded. + """, + ) + parser.add_argument( + "-C", + "--config-key-cred-cmd", + metavar="KEY_CRED_CMD", + default="carddav-source-cred-cmd", + help=""" + INI configuration key to lookup in CONFIG_SECTION from CONFIG_FILE. The + value is a command that will be used to determine PASSWORD if it is not + present in CONFIG_KEY_SOURCE. + """, + ) + parser.add_argument( + "-s", + "--server-url", + help=""" + CardDAV server URL endpoint. Overrides configuration file. + """, + ) + parser.add_argument( + "-u", + "--username", + help=""" + Username to authenticate on the server. Overrides configuration file. + """, + ) + parser.add_argument( + "-p", + "--password", + help=""" + Password for the specified user. Overrides configuration file. + """, + ) + parser.add_argument( + "terms", + nargs="+", + metavar="TERM", + help=""" + Search term. Will be used to search contacts from their FN (formatted + name), EMAIL, NICKNAME, ORG (company) and TITLE fields. + """, + ) + args = parser.parse_args() + + cfg = configparser.RawConfigParser(strict=False) + cfg.read([args.config_file]) + source = cred_cmd = None + if args.config_section: + source = cfg.get(args.config_section, args.config_key_source, fallback=None) + cred_cmd = cfg.get(args.config_section, args.config_key_cred_cmd, fallback=None) + else: + for sec in cfg.sections(): + source = cfg.get(sec, args.config_key_source, fallback=None) + if source is not None: + cred_cmd = cfg.get(sec, args.config_key_cred_cmd, fallback=None) + break + if source is not None: + try: + u = parse.urlparse(source) + if args.username is None and u.username is not None: + args.username = parse.unquote(u.username) + if args.password is None and u.password is not None: + args.password = parse.unquote(u.password) + if not args.password and cred_cmd is not None: + args.password = subprocess.check_output( + cred_cmd, shell=True, text=True, encoding="utf-8" + ).strip() + if args.server_url is None: + args.server_url = f"{u.scheme}://{u.hostname}" + if u.port is not None: + args.server_url += f":{u.port}" + args.server_url += u.path + except ValueError as e: + parser.error(f"{args.config_file}: {e}") + if args.server_url is None: + parser.error("SERVER_URL is required") + + return args + + +if __name__ == "__main__": + main() diff --git a/contrib/check-docs b/contrib/check-docs new file mode 100755 index 0000000..a8187d9 --- /dev/null +++ b/contrib/check-docs @@ -0,0 +1,67 @@ +#!/bin/sh + +tmp=$(mktemp) +trap "rm -f $tmp" EXIT + +global_fail=0 + +cmd_scd_sed='s/^\*:([a-z][a-z -]*)\*.*/\1/p' +cmd_go_sed='/^func ([[:alnum:]][[:alnum:]]*) Aliases() \[\]string {$/{n; + s/", "/ /g; + s/.*return \[\]string{"\(.*\)"}/\1/p +}' + +grep_color= +if echo . | grep --color . >/dev/null 2>&1; then + grep_color=--color +fi + +fail=0 +sed -nE "$cmd_scd_sed" doc/*.scd | tr ' ' '\n' > "$tmp" +for f in $(find commands -type f -name '*.go'); do + for cmd in $(sed -n "$cmd_go_sed" "$f"); do + if ! grep -qFx "$cmd" "$tmp"; then + grep -HnF $grep_color "\"$cmd\"" "$f" + fail=$((fail+1)) + fi + done +done + +if [ "$fail" -gt 0 ]; then + echo "error: $fail command(s) not documented in man pages" >&2 + global_fail=1 +fi + +fail=0 +sed -n "$cmd_go_sed" $(find commands -type f -name '*.go') | tr ' ' '\n' > "$tmp" +for f in doc/*.scd; do + for cmd in $(sed -nE "$cmd_scd_sed" "$f" | tr ' ' '\n' | sed '/^-/d;/^$/d'); do + if ! grep -qFx "$cmd" "$tmp"; then + grep -Hn $grep_color "^\\*:$cmd\\*" "$f" + fail=$((fail+1)) + fi + done +done + +if [ "$fail" -gt 0 ]; then + echo "error: $fail non-existent command(s) documented in man pages" >&2 + global_fail=1 +fi + +fail=0 +sed -nE 's/^\*([a-z][a-z-]*)\* = .*/\1/p' doc/*.scd > "$tmp" +for f in $(find config -type f -name '*.go'); do + for opt in $(sed -nE 's/.*`ini:"([a-z][a-z-]*)".*/\1/p' $f); do + if ! grep -qFx "$opt" "$tmp"; then + grep -HnF $grep_color "\"$opt\"" "$f" + fail=$((fail+1)) + fi + done +done + +if [ "$fail" -gt 0 ]; then + echo "error: $fail option(s) not documented in man pages" >&2 + global_fail=1 +fi + +exit $global_fail diff --git a/contrib/check-patches b/contrib/check-patches new file mode 100755 index 0000000..7931960 --- /dev/null +++ b/contrib/check-patches @@ -0,0 +1,95 @@ +#!/bin/sh + +set -e + +revision_range="${1?revision range}" + +valid=0 +revisions=$(git rev-list --reverse "$revision_range") +total=$(echo $revisions | wc -w) +if [ "$total" -eq 0 ]; then + exit 0 +fi + +allowed_trailers=" +Fixes +Implements +References +Link +Changelog-added +Changelog-fixed +Changelog-changed +Changelog-deprecated +Cc +Suggested-by +Requested-by +Reported-by +Co-authored-by +Signed-off-by +Tested-by +Reviewed-by +Acked-by +" + + +n=0 +title= +fail=false + +err() { + echo "error [PATCH $n/$total] '$title' $*" >&2 + fail=true +} + +for rev in $revisions; do + n=$((n + 1)) + title=$(git log --format='%s' -1 "$rev") + fail=false + + if [ "$(echo "$title" | wc -m)" -gt 72 ]; then + err "title is longer than 72 characters, please make it shorter" + fi + + if ! echo "$title" | grep -qE '^[a-z0-9,{}/_-]+: '; then + err "title lacks a topic prefix (e.g. 'imap:')" + fi + + author=$(git log --format='%an <%ae>' -1 "$rev") + if ! git log --format="%(trailers:key=Signed-off-by,only,valueonly,unfold)" -1 "$rev" | + grep -qFx "$author"; then + err "'Signed-off-by: $author' trailer is missing" + fi + + for trailer in $(git log --format="%(trailers:only,keyonly)" -1 "$rev"); do + if ! echo "$allowed_trailers" | grep -qFx "$trailer"; then + err "trailer '$trailer' is misspelled or not in the sanctioned list" + fi + done + + if git log --format="%(trailers:only,unfold)" -1 "$rev" | \ + grep -vE '^Changelog-[a-z]+: [A-Z`\*_].+\.$' | \ + grep -qE '^Changelog-[a-z]+: '; then + err "Changelog-* trailers should start with a capital letter and end with a period" + fi + + body=$(git log --format='%b' -1 "$rev") + body=${body%$(git log --format='%(trailers)' -1 "$rev")} + if [ "$(echo "$body" | wc -w)" -lt 3 ]; then + err "body has less than three words, please describe your changes" + fi + + if ! git log --format='%s%n%b' -1 "$rev" | codespell -; then + err "typos in title and/or body" + fi + + if [ "$fail" = true ]; then + continue + fi + echo "ok [PATCH $n/$total] '$title'" + valid=$((valid + 1)) +done + +echo "$valid/$total valid patches" +if [ "$valid" -ne "$total" ]; then + exit 1 +fi diff --git a/contrib/check-whitespace b/contrib/check-whitespace new file mode 100755 index 0000000..5afd2f4 --- /dev/null +++ b/contrib/check-whitespace @@ -0,0 +1,51 @@ +#!/usr/bin/awk -f + +BEGIN { + isatty = system("test -t 1") == "0" + retcode = 0 +} + +function color(code, s) { + if (isatty) { + return "\033[" code "m" s "\033[0m" + } + return s +} +function red(s) { return color("31", s) } +function green(s) { return color("32", s) } +function magenta(s) { return color("35", s) } +function cyan(s) { return color("36", s) } +function bg_red(s) { return color("41", s) } +function hl_ws(s, pattern) { + gsub(pattern, bg_red("&"), s) + # convert tab characters to 8 spaces to allow coloring + gsub(/\t/, " ", s) + return s +} + +/ +\t+/ { + retcode = 1 + print magenta(FILENAME) cyan(":") green(FNR) cyan(":") \ + hl_ws($0, " +\\t+") red("<-- space(s) followed by tab(s)") +} + +/[ \t]+$/ { + retcode = 1 + print magenta(FILENAME) cyan(":") green(FNR) cyan(":") \ + hl_ws($0, "[ \\t]+$") red("<-- trailing whitespace") +} + +ENDFILE { + # will only match on GNU awk, ignored on non-GNU versions + if ($0 ~ /^[ \t]*$/) { + retcode = 1 + print magenta(FILENAME) cyan(": ") red("trailing new line(s)") + } else if (RT != "\n") { + retcode = 1 + print magenta(FILENAME) cyan(": ") red("no new line at end of file") + } +} + +END { + exit retcode +} diff --git a/contrib/commit-msg b/contrib/commit-msg new file mode 100755 index 0000000..503b5a1 --- /dev/null +++ b/contrib/commit-msg @@ -0,0 +1,80 @@ +#!/bin/sh + +set -e + +debug() { + if [ "$GIT_TRAILER_DEBUG" = 1 ]; then + "$@" >&2 + fi +} + +trailer_order=" +Closes: +Fixes: +Implements: +References: +Link: +Changelog-added: +Changelog-fixed: +Changelog-changed: +Changelog-deprecated: +Cc: +Suggested-by: +Requested-by: +Reported-by: +Co-authored-by: +Signed-off-by: +Tested-by: +Reviewed-by: +Acked-by: +" +file=${1?file} +tmp=$(mktemp) +trap "rm -f $tmp" EXIT + +# Read unfolded trailers and normalize case. +git interpret-trailers --parse --trim-empty "$file" | +while read -r key value; do + # Force title case on trailer key. + first_letter=$(echo "$key" | sed 's/^\(.\).*/\1/' | tr '[:lower:]' '[:upper:]') + other_letters=$(echo "$key" | sed 's/^.\(.*\)/\1/' | tr '[:upper:]' '[:lower:]') + key="$first_letter$other_letters" + + # Find sort order of this key. + order=$(echo "$trailer_order" | grep -Fxn "$key" | sed -nE 's/^([0-9]+):.*/\1/p') + if [ -z "$order" ]; then + echo "warning: unknown trailer '$key'" >&2 + # Unknown trailers are always first. + order="0" + fi + + echo "$order $key $value" +done | +# Sort trailers according to their numeric order, trim the numeric order. +LC_ALL=C sort -n | sed -E 's/^[0-9]+ //' > "$tmp" + +debug echo ==== sanitized trailers ==== +debug cat "$tmp" + +# Unfortunately, reordering trailers is not possible at the moment. Delete all +# trailers first. The only way to do it is to force replace existing trailers +# with empty values and trim empty trailers one by one. +while read -r key value; do + git interpret-trailers --in-place --if-exists=replace \ + --trailer="$key " "$file" + git interpret-trailers --in-place --trim-empty "$file" +done < "$tmp" + +set -- +while read -r trailer; do + case "$trailer" in + Changelog-*) + # Wrap changelog entries indenting with a space. + trailer=$(echo "$trailer" | fmt -w 72 | sed '2,$s/^/ /') + ;; + esac + set -- "$@" --trailer="$trailer" +done < "$tmp" + +# Remove duplicate "key: value" trailers (e.g. duplicate signed-off-by). +git interpret-trailers --in-place --if-exists=addIfDifferent "$@" "$file" diff --git a/contrib/depends-diff.py b/contrib/depends-diff.py new file mode 100755 index 0000000..4537612 --- /dev/null +++ b/contrib/depends-diff.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Copyright (c) 2024 Robin Jarry + +import argparse +import re +import subprocess + +DEP_CHANGE_RE = re.compile( + r""" + ^ + (?P<diff>[\+\-])\s* + (?P<name>\S+)\s* + (?P<version>v\S+)\s* + (?://\s*indirect)? + $ + """, + re.VERBOSE, +) +REPLACE_RE = re.compile( + r""" + ^ + (?P<diff>[\+\-])\s* + replace + (?P<name>\S+)\s* + =>\s* + (?P<replacement>\S+)\s* + (?P<version>v\S+)\s* + $ + """, + re.VERBOSE, +) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "git_range", + metavar="GIT_RANGE", + help="The git revision range (see gitrevisions(7)).", + ) + args = parser.parse_args() + + old_deps = {} + new_deps = {} + + with subprocess.Popen( + ["git", "diff", "-U0", "--ignore-all-space", args.git_range, "--", "go.mod"], + stdout=subprocess.PIPE, + encoding="utf-8", + ) as proc: + for line in proc.stdout: + match = DEP_CHANGE_RE.match(line.strip()) + if not match: + match = REPLACE_RE.match(line.strip()) + if not match: + continue + diff, name, replacement, version = match.groups() + if diff == "+": + new_deps[replacement] = version + del new_deps[name] + continue + diff, name, version = match.groups() + if diff == "+": + new_deps[name] = version + else: + old_deps[name] = version + + once = False + added = new_deps.keys() - old_deps.keys() + if added: + print("## New") + print() + for a in sorted(added): + print("+", a, new_deps[a]) + once = True + + updated = old_deps.keys() & new_deps.keys() + if updated: + if once: + print() + print("## Updated") + print() + for u in sorted(updated): + print("*", u, old_deps[u], "=>", new_deps[u]) + once = True + + removed = old_deps.keys() - new_deps.keys() + if removed: + if once: + print() + print("## Removed") + print() + for r in sorted(removed): + print("-", r) + once = True + + if not once: + print("none") + + +if __name__ == "__main__": + main() diff --git a/contrib/git-stats-graph.py b/contrib/git-stats-graph.py new file mode 100755 index 0000000..dc944c1 --- /dev/null +++ b/contrib/git-stats-graph.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Copyright (c) 2023 Bence Ferdinandy <bence@ferdinandy.com> + +""" +Create graphs about development statistics of releases. +""" + +from datetime import date +from subprocess import check_output + +from matplotlib import pyplot as plt + + +def git(*args): + return check_output(["git"] + list(args)).decode("utf-8").strip() + + +def stats(): + """ + Returns statistics from the git repo: + + tags: sorted list of minor version tags (assumes semver) + The first element is the hash of the first commit, last element is HEAD. All + the other return values are one shorter as, there's no statistics returned for + the first commit. + counts: number of commits (between this and the previous release) + dates: dates of the releases + files: number of files changed (between this and the previous release) + inserts: number of lines inserted (between this and the previous release) + deletions: number of lines deleted (between this and the previous release) + """ + + tags = git("tag").split("\n") + tags = [t for t in tags if t.split(".")[-1] == "0"] # drop patch versions + tags = sorted(tags, key=lambda x: [int(t) for t in x.split(".")]) + first_commit = git("rev-list", "--max-parents=0", "HEAD") + tags = [first_commit] + tags + ["HEAD"] + counts = [] + dates = [] + files = [] + inserts = [] + deletions = [] + for i, t in enumerate(tags[:-1]): + counts.append(int(git("rev-list", f"{t}..{tags[i+1]}", "--count"))) + dates.append( + date.fromisoformat( + git("show", "-s", "--format=%cs", tags[i + 1]).split("\n")[-1] + ) + ) + statline = git("diff", "--stat", t, tags[i + 1]).split("\n")[-1] + fnum, _, _, ins, _, dels, _ = statline.split() + files.append(int(fnum)) + inserts.append(int(ins)) + deletions.append(int(dels)) + return tags, counts, dates, files, inserts, deletions + + +def main(output): + tags, counts, dates, files, inserts, deletions = stats() + + fig, (ax1, ax2) = plt.subplots(2, figsize=(8, 11)) + fig.suptitle("aerc release statistics", fontweight="bold") + # commit counts subplot + ax1.plot(dates, counts, "o-") + + # alternate placement of text above and below for readability + text_y = [] + for i, t in enumerate(tags[1:]): + downpad = 25 if len(t) == 5 else 30 + p = counts[i] + (-1) ** (i + 1) * 10 - (i + 1) % 2 * downpad + if p < 5: + p = counts[i] + 10 + text_y.append(p) + for i, t in enumerate(tags[1:]): + ax1.text( + dates[i], + text_y[i], + t, + horizontalalignment="center", + rotation="vertical", + ) + ax1.set_ylabel("# of commits") + ax1.set_ylim(bottom=0) + ax1.set_title("commits per release") + + # lines added/deleted subplot + # + ax2.plot(dates, inserts, "o-", label="insertions(+)", color="green") + ax2.plot(dates, deletions, "o-.", label="deletions(-)", color="red") + ax2.set_ylabel("# of lines") + ax2.legend(loc="upper left") + ax2.set_ylim(top=max(max(inserts), max(deletions)) + 2000) + for i, t in enumerate(tags[1:]): + ax2.text( + dates[i], + max(inserts[i], deletions[i]) + 500, + t, + horizontalalignment="center", + rotation="vertical", + ) + ax2.set_xlabel("date") + ax2.set_title("insertion/deletions per release") + plt.tight_layout() + plt.savefig(output, dpi=300) + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "-o", + "--output", + default="aerc-release-stats.png", + help=""" + Path to output image (defaults to 'aerc-release-stats.png', + respects file extensions via matplotlib) + """, + ) + args = parser.parse_args() + main(args.output) diff --git a/contrib/git-stats.sh b/contrib/git-stats.sh new file mode 100755 index 0000000..95121fc --- /dev/null +++ b/contrib/git-stats.sh @@ -0,0 +1,38 @@ +#!/bin/bash + +set -e +set -o pipefail + +columns="Author,Commits,Changed Files,Insertions,Deletions" + +git shortlog -sn "$@" | +while read -r commits author; do + git log --author="$author" --pretty=tformat: --numstat "$@" | { + adds=0 + subs=0 + files=0 + while read -r a s f; do + adds=$((adds + a)) + subs=$((subs + s)) + files=$((files + 1)) + done + printf '%s;%d;%d;%+d;%+d;\n' \ + "$author" "$commits" "$files" "$adds" "-$subs" + } +done | +column -t -s ';' -N "$columns" -R "${columns#*,}" | +sed -E 's/[[:space:]]+$//' + +echo + +columns="Reviewer/Tester,Commits" + +git shortlog -sn \ + --group=trailer:acked-by \ + --group=trailer:tested-by \ + --group=trailer:reviewed-by "$@" | +while read -r commits author; do + printf '%s;%s\n' "$author" "$commits" +done | +column -t -s ';' -N "$columns" -R "${columns#*,}" | +sed -E 's/[[:space:]]+$//' diff --git a/contrib/goflags.sh b/contrib/goflags.sh new file mode 100755 index 0000000..cadf9e1 --- /dev/null +++ b/contrib/goflags.sh @@ -0,0 +1,23 @@ +#!/bin/sh + +set -e + +tags= + +if ${CC:-cc} -x c - -o/dev/null -lnotmuch 2>/dev/null; then + tags="$tags,notmuch" +fi <<EOF +#include <notmuch.h> + +#if !LIBNOTMUCH_CHECK_VERSION(5, 6, 0) +#error "aerc requires libnotmuch.so.5.6 or later" +#endif + +void main(void) { + notmuch_status_to_string(NOTMUCH_STATUS_SUCCESS); +} +EOF + +if [ -n "$tags" ]; then + printf -- '-tags=%s\n' "${tags#,}" +fi diff --git a/contrib/ircbot/Karma/__init__.py b/contrib/ircbot/Karma/__init__.py new file mode 100644 index 0000000..dc99727 --- /dev/null +++ b/contrib/ircbot/Karma/__init__.py @@ -0,0 +1,63 @@ +### +# Copyright (c) 2005, Jeremiah Fincher +# Copyright (c) 2010-2021, Valentin Lorentz +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +""" +Plugin for keeping track of Karma for users and things in a channel. +""" + +import supybot +import supybot.world as world + +# Use this for the version of this plugin. You may wish to put a CVS keyword +# in here if you're keeping the plugin in CVS or some similar system. +__version__ = "" + +__author__ = supybot.authors.jemfinch +__maintainer__ = supybot.authors.limnoria_core + +# This is a dictionary mapping supybot.Author instances to lists of +# contributions. +__contributors__ = {} + +from . import config +from . import plugin +from importlib import reload +reload(plugin) # In case we're being reloaded. +# Add more reloads here if you add third-party modules and want them to be +# reloaded when this plugin is reloaded. Don't forget to import them as well! + +if world.testing: + from . import test + +Class = plugin.Class +configure = config.configure + + +# vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79: diff --git a/contrib/ircbot/Karma/config.py b/contrib/ircbot/Karma/config.py new file mode 100644 index 0000000..b63e910 --- /dev/null +++ b/contrib/ircbot/Karma/config.py @@ -0,0 +1,74 @@ +### +# Copyright (c) 2005, Jeremiah Fincher +# Copyright (c) 2010-2021, Valentin Lorentz +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +import supybot.conf as conf +import supybot.registry as registry +from supybot.i18n import PluginInternationalization, internationalizeDocstring +_ = PluginInternationalization('Karma') + +def configure(advanced): + # This will be called by supybot to configure this module. advanced is + # a bool that specifies whether the user identified themself as an advanced + # user or not. You should effect your configuration by manipulating the + # registry as appropriate. + from supybot.questions import expect, anything, something, yn + conf.registerPlugin('Karma', True) + +Karma = conf.registerPlugin('Karma') + +conf.registerChannelValue(Karma, 'simpleOutput', + registry.Boolean(False, _("""Determines whether the bot will output shorter + versions of the karma output when requesting a single thing's karma."""))) +conf.registerChannelValue(Karma, 'incrementChars', + registry.SpaceSeparatedListOfStrings(['++'], _("""A space separated list of + characters to increase karma."""))) +conf.registerChannelValue(Karma, 'decrementChars', + registry.SpaceSeparatedListOfStrings(['--'], _("""A space separated list of + characters to decrease karma."""))) +conf.registerChannelValue(Karma, 'response', + registry.Boolean(False, _("""Determines whether the bot will reply with a + success message when something's karma is increased or decreased."""))) +conf.registerChannelValue(Karma, 'rankingDisplay', + registry.Integer(3, _("""Determines how many highest/lowest karma things + are shown when karma is called with no arguments."""))) +conf.registerChannelValue(Karma, 'mostDisplay', + registry.Integer(25, _("""Determines how many karma things are shown when + the most command is called."""))) +conf.registerChannelValue(Karma, 'allowSelfRating', + registry.Boolean(False, _("""Determines whether users can adjust the karma + of their nick."""))) +conf.registerChannelValue(Karma, 'allowUnaddressedKarma', + registry.Boolean(True, _("""Determines whether the bot will + increase/decrease karma without being addressed."""))) +conf.registerChannelValue(Karma, 'onlyNicks', + registry.Boolean(False, _("""Determines whether the bot will + only increase/decrease karma for nicks in the current channel."""))) + +# vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79: diff --git a/contrib/ircbot/Karma/plugin.py b/contrib/ircbot/Karma/plugin.py new file mode 100644 index 0000000..dbc64fc --- /dev/null +++ b/contrib/ircbot/Karma/plugin.py @@ -0,0 +1,469 @@ +### +# Copyright (c) 2005, Jeremiah Fincher +# Copyright (c) 2010, James McCoy +# Copyright (c) 2010-2021, Valentin Lorentz +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions, and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions, and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the author of this software nor the name of +# contributors to this software may be used to endorse or promote products +# derived from this software without specific prior written consent. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +### + +import os +import re +import sys +import csv +import time +import random + +import supybot.conf as conf +import supybot.utils as utils +from supybot.commands import * +import supybot.utils.minisix as minisix +import supybot.plugins as plugins +import supybot.ircmsgs as ircmsgs +import supybot.ircutils as ircutils +import supybot.callbacks as callbacks +import supybot.schedule as schedule +from supybot.i18n import PluginInternationalization, internationalizeDocstring +_ = PluginInternationalization('Karma') + +import sqlite3 + +def checkAllowShell(irc): + if not conf.supybot.commands.allowShell(): + irc.error('This command is not available, because ' + 'supybot.commands.allowShell is False.', Raise=True) + +class SqliteKarmaDB(object): + def __init__(self, filename): + self.dbs = ircutils.IrcDict() + self.filename = filename + + def close(self): + for db in self.dbs.values(): + db.close() + + def _getDb(self, channel): + filename = plugins.makeChannelFilename(self.filename, channel) + if filename in self.dbs: + return self.dbs[filename] + if os.path.exists(filename): + db = sqlite3.connect(filename, check_same_thread=False) + if minisix.PY2: + db.text_factory = str + self.dbs[filename] = db + return db + db = sqlite3.connect(filename, check_same_thread=False) + if minisix.PY2: + db.text_factory = str + self.dbs[filename] = db + cursor = db.cursor() + cursor.execute("""CREATE TABLE karma ( + id INTEGER PRIMARY KEY, + name TEXT, + normalized TEXT UNIQUE ON CONFLICT IGNORE, + added INTEGER, + subtracted INTEGER + )""") + db.commit() + def p(s1, s2): + return int(ircutils.nickEqual(s1, s2)) + db.create_function('nickeq', 2, p) + return db + + def get(self, channel, thing): + db = self._getDb(channel) + thing = thing.lower() + cursor = db.cursor() + cursor.execute("""SELECT added, subtracted FROM karma + WHERE normalized=?""", (thing,)) + results = cursor.fetchall() + if len(results) == 0: + return None + else: + return list(map(int, results[0])) + + def gets(self, channel, things): + db = self._getDb(channel) + cursor = db.cursor() + normalizedThings = dict(list(zip([s.lower() for s in things], things))) + criteria = ' OR '.join(['normalized=?'] * len(normalizedThings)) + sql = """SELECT name, added-subtracted FROM karma + WHERE %s ORDER BY added-subtracted DESC""" % criteria + cursor.execute(sql, list(normalizedThings.keys())) + L = [(name, int(karma)) for (name, karma) in cursor.fetchall()] + for (name, _) in L: + del normalizedThings[name.lower()] + neutrals = list(normalizedThings.values()) + neutrals.sort() + return (L, neutrals) + + def top(self, channel, limit): + db = self._getDb(channel) + cursor = db.cursor() + cursor.execute("""SELECT name, added-subtracted FROM karma + ORDER BY added-subtracted DESC LIMIT ?""", (limit,)) + return [(t[0], int(t[1])) for t in cursor.fetchall()] + + def bottom(self, channel, limit): + db = self._getDb(channel) + cursor = db.cursor() + cursor.execute("""SELECT name, added-subtracted FROM karma + ORDER BY added-subtracted ASC LIMIT ?""", (limit,)) + return [(t[0], int(t[1])) for t in cursor.fetchall()] + + def rank(self, channel, thing): + db = self._getDb(channel) + cursor = db.cursor() + cursor.execute("""SELECT added-subtracted FROM karma + WHERE name=?""", (thing,)) + results = cursor.fetchall() + if len(results) == 0: + return None + karma = int(results[0][0]) + cursor.execute("""SELECT COUNT(*) FROM karma + WHERE added-subtracted > ?""", (karma,)) + rank = int(cursor.fetchone()[0]) + return rank+1 + + def size(self, channel): + db = self._getDb(channel) + cursor = db.cursor() + cursor.execute("""SELECT COUNT(*) FROM karma""") + return int(cursor.fetchone()[0]) + + def increment(self, channel, name): + db = self._getDb(channel) + cursor = db.cursor() + normalized = name.lower() + cursor.execute("""INSERT INTO karma VALUES (NULL, ?, ?, 0, 0)""", + (name, normalized,)) + cursor.execute("""UPDATE karma SET added=added+1 + WHERE normalized=?""", (normalized,)) + db.commit() + + def decrement(self, channel, name): + db = self._getDb(channel) + cursor = db.cursor() + normalized = name.lower() + cursor.execute("""INSERT INTO karma VALUES (NULL, ?, ?, 0, 0)""", + (name, normalized,)) + cursor.execute("""UPDATE karma SET subtracted=subtracted+1 + WHERE normalized=?""", (normalized,)) + db.commit() + + def most(self, channel, kind, limit): + if kind == 'increased': + orderby = 'added' + elif kind == 'decreased': + orderby = 'subtracted' + elif kind == 'active': + orderby = 'added+subtracted' + else: + raise ValueError('invalid kind') + sql = """SELECT name, %s FROM karma ORDER BY %s DESC LIMIT %s""" % \ + (orderby, orderby, limit) + db = self._getDb(channel) + cursor = db.cursor() + cursor.execute(sql) + return [(name, int(i)) for (name, i) in cursor.fetchall()] + + def clear(self, channel, name=None): + db = self._getDb(channel) + cursor = db.cursor() + if name: + normalized = name.lower() + cursor.execute("""DELETE FROM karma + WHERE normalized=?""", (normalized,)) + else: + cursor.execute("""DELETE FROM karma""") + db.commit() + + def dump(self, channel, filename): + filename = conf.supybot.directories.data.dirize(filename) + fd = utils.file.AtomicFile(filename) + out = csv.writer(fd) + db = self._getDb(channel) + cursor = db.cursor() + cursor.execute("""SELECT name, added, subtracted FROM karma""") + for (name, added, subtracted) in cursor.fetchall(): + out.writerow([name, added, subtracted]) + fd.close() + + def load(self, channel, filename): + filename = conf.supybot.directories.data.dirize(filename) + fd = open(filename, encoding='utf8') + reader = csv.reader(fd) + db = self._getDb(channel) + cursor = db.cursor() + cursor.execute("""DELETE FROM karma""") + for (name, added, subtracted) in reader: + normalized = name.lower() + cursor.execute("""INSERT INTO karma + VALUES (NULL, ?, ?, ?, ?)""", + (name, normalized, added, subtracted,)) + db.commit() + fd.close() + +KarmaDB = plugins.DB('Karma', + {'sqlite3': SqliteKarmaDB}) + +class Karma(callbacks.Plugin): + """ + Provides a simple tracker for setting Karma (thing++, thing--). + If ``config plugins.karma.allowUnaddressedKarma`` is set to ``True`` + (default since 2014.05.07), saying `boats++` will give 1 karma + to ``boats``, and ``ships--`` will subtract 1 karma from ``ships``. + + However, if you use this in a sentence, like + ``That deserves a ++. Kevin++``, 1 karma will be added to + ``That deserves a ++. Kevin``, so you should only add or subtract karma + in a line that doesn't have anything else in it. + Alternatively, you can restrict karma tracking to nicks in the current + channel by setting `config plugins.Karma.onlyNicks` to ``True``. + + If ``config plugins.karma.allowUnaddressedKarma` is set to `False``, + you must address the bot with nick or prefix to add or subtract karma. + """ + callBefore = ('Factoids', 'MoobotFactoids', 'Infobot') + def __init__(self, irc): + self.__parent = super(Karma, self) + self.__parent.__init__(irc) + self.db = KarmaDB() + + def die(self): + self.__parent.die() + self.db.close() + + def _normalizeThing(self, thing): + assert thing + if thing[0] == '(' and thing[-1] == ')': + thing = thing[1:-1] + return thing + + def _respond(self, irc, channel, thing, karma): + if self.registryValue('response', channel, irc.network): + irc.reply(_('%(thing)s\'s karma is now %(karma)i') % + {'thing': thing, 'karma': karma}) + else: + irc.noReply() + + IRC_NICK = r'\w[\w\\`\[\]\{\}\^-]*' + TABLE_FLIP = re.compile(r'\s*[(\(]╯...[\))]╯\s*︵\s*.*', re.U) + + def _doKarma(self, irc, msg, channel, line): + match = self.TABLE_FLIP.match(line) + if match: + event_name = f'unflip {msg.nic}' + try: + schedule.removeEvent(event_name) + except KeyError: + pass + schedule.addEvent( + irc.reply, + time.time() + random.lognormvariate(0.5, 0.5), + name=event_name, + args=['┳━┳ノ(°_°ノ)'], + ) + return + + inc = self.registryValue('incrementChars', channel, irc.network) + dec = self.registryValue('decrementChars', channel, irc.network) + onlynicks = self.registryValue('onlyNicks', channel, irc.network) + karma = {} + for s in inc: + regex = re.compile(rf'\b({self.IRC_NICK})\b{re.escape(s)}') + for match in regex.finditer(line): + thing = match.group(1) + # Don't reply if the target isn't a nick + if onlynicks and thing.lower() not in map(ircutils.toLower, + irc.state.channels[channel].users): + return + if ircutils.strEqual(thing, msg.nick) and \ + not self.registryValue('allowSelfRating', + channel, irc.network): + irc.error(_('You\'re not allowed to adjust your own karma.')) + return + self.db.increment(channel, self._normalizeThing(thing)) + scores = self.db.get(channel, self._normalizeThing(thing)) + if scores: + karma[thing] = scores[0] - scores[1] + for s in dec: + regex = re.compile(rf'\b({self.IRC_NICK})\b{re.escape(s)}') + for match in regex.finditer(line): + thing = match.group(1) + if onlynicks and thing.lower() not in map(ircutils.toLower, + irc.state.channels[channel].users): + return + if ircutils.strEqual(thing, msg.nick) and \ + not self.registryValue('allowSelfRating', + channel, irc.network): + irc.error(_('You\'re not allowed to adjust your own karma.')) + return + self.db.decrement(channel, self._normalizeThing(thing)) + scores = self.db.get(channel, self._normalizeThing(thing)) + if scores: + karma[thing] = scores[0] - scores[1] + for thing, score in karma.items(): + self._respond(irc, channel, thing, score) + + def invalidCommand(self, irc, msg, tokens): + if msg.channel and tokens: + line = msg.args[1].rstrip() + self._doKarma(irc, msg, msg.channel, line) + + def doPrivmsg(self, irc, msg): + # We don't handle this if we've been addressed because invalidCommand + # will handle it for us. This prevents us from accessing the db twice + # and therefore crashing. + if not (msg.addressed or msg.repliedTo): + if msg.channel and \ + not ircmsgs.isCtcp(msg) and \ + self.registryValue('allowUnaddressedKarma', + msg.channel, irc.network): + irc = callbacks.SimpleProxy(irc, msg) + line = msg.args[1].rstrip() + self._doKarma(irc, msg, msg.channel, line) + + @internationalizeDocstring + def karma(self, irc, msg, args, channel, things): + """[<channel>] [<thing> ...] + + Returns the karma of <thing>. If <thing> is not given, returns the top + N karmas, where N is determined by the config variable + supybot.plugins.Karma.rankingDisplay. If one <thing> is given, returns + the details of its karma; if more than one <thing> is given, returns + the total karma of each of the things. <channel> is only necessary + if the message isn't sent on the channel itself. + """ + if len(things) == 1: + name = things[0] + t = self.db.get(channel, name) + if t is None: + irc.reply(format(_('%s has neutral karma.'), name)) + else: + (added, subtracted) = t + total = added - subtracted + if self.registryValue('simpleOutput', channel, irc.network): + s = format('%s: %i', name, total) + else: + s = format(_('Karma for %q has been increased %n and ' + 'decreased %n for a total karma of %s.'), + name, (added, _('time')), + (subtracted, _('time')), + total) + irc.reply(s) + elif len(things) > 1: + (L, neutrals) = self.db.gets(channel, things) + if L: + s = format('%L', [format('%s: %i', *t) for t in L]) + if neutrals: + neutral = format('. %L %h neutral karma', + neutrals, len(neutrals)) + s += neutral + irc.reply(s + '.') + else: + irc.reply(_('I didn\'t know the karma for any of those ' + 'things.')) + else: # No name was given. Return the top/bottom N karmas. + limit = self.registryValue('rankingDisplay', channel, irc.network) + highest = [format('%q (%s)', s, t) + for (s, t) in self.db.top(channel, limit)] + lowest = [format('%q (%s)', s, t) + for (s, t) in self.db.bottom(channel, limit)] + if not (highest and lowest): + irc.error(_('I have no karma for this channel.')) + return + rank = self.db.rank(channel, msg.nick) + if rank is not None: + total = self.db.size(channel) + rankS = format(_(' You (%s) are ranked %i out of %i.'), + msg.nick, rank, total) + else: + rankS = '' + s = format(_('Highest karma: %L. Lowest karma: %L.%s'), + highest, lowest, rankS) + irc.reply(s) + karma = wrap(karma, ['channel', any('something')]) + + _mostAbbrev = utils.abbrev(['increased', 'decreased', 'active']) + @internationalizeDocstring + def most(self, irc, msg, args, channel, kind): + """[<channel>] {increased,decreased,active} + + Returns the most increased, the most decreased, or the most active + (the sum of increased and decreased) karma things. <channel> is only + necessary if the message isn't sent in the channel itself. + """ + L = self.db.most(channel, kind, + self.registryValue('mostDisplay', + channel, irc.network)) + if L: + L = [format('%q: %i', name, i) for (name, i) in L] + irc.reply(format('%L', L)) + else: + irc.error(_('I have no karma for this channel.')) + most = wrap(most, ['channel', + ('literal', ['increased', 'decreased', 'active'])]) + + @internationalizeDocstring + def clear(self, irc, msg, args, channel, name): + """[<channel>] [<name>] + + Resets the karma of <name> to 0. If <name> is not given, resets + everything. + """ + self.db.clear(channel, name or None) + irc.replySuccess() + clear = wrap(clear, [('checkChannelCapability', 'op'), optional('text')]) + + @internationalizeDocstring + def dump(self, irc, msg, args, channel, filename): + """[<channel>] <filename> + + Dumps the Karma database for <channel> to <filename> in the bot's + data directory. <channel> is only necessary if the message isn't sent + in the channel itself. + """ + checkAllowShell(irc) + self.db.dump(channel, filename) + irc.replySuccess() + dump = wrap(dump, [('checkCapability', 'owner'), 'channeldb', 'filename']) + + @internationalizeDocstring + def load(self, irc, msg, args, channel, filename): + """[<channel>] <filename> + + Loads the Karma database for <channel> from <filename> in the bot's + data directory. <channel> is only necessary if the message isn't sent + in the channel itself. + """ + checkAllowShell(irc) + self.db.load(channel, filename) + irc.replySuccess() + load = wrap(load, [('checkCapability', 'owner'), 'channeldb', 'filename']) + +Class = Karma + +# vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79: diff --git a/contrib/ircbot/Sourcehut/README.md b/contrib/ircbot/Sourcehut/README.md new file mode 100644 index 0000000..dbc4311 --- /dev/null +++ b/contrib/ircbot/Sourcehut/README.md @@ -0,0 +1 @@ +Supybot plugin to receive Sourcehut webhooks diff --git a/contrib/ircbot/Sourcehut/__init__.py b/contrib/ircbot/Sourcehut/__init__.py new file mode 100644 index 0000000..39a9bee --- /dev/null +++ b/contrib/ircbot/Sourcehut/__init__.py @@ -0,0 +1,21 @@ +""" +Sourcehut: Supybot plugin to receive Sourcehut webhooks +""" + +import sys +import supybot + +__version__ = "0.1" +__author__ = supybot.authors.unknown +__contributors__ = {} +__url__ = '' + +from . import config +from . import plugin +from importlib import reload + +reload(config) +reload(plugin) + +Class = plugin.Class +configure = config.configure diff --git a/contrib/ircbot/Sourcehut/config.py b/contrib/ircbot/Sourcehut/config.py new file mode 100644 index 0000000..38e5542 --- /dev/null +++ b/contrib/ircbot/Sourcehut/config.py @@ -0,0 +1,14 @@ +from supybot import conf, registry +try: + from supybot.i18n import PluginInternationalization + _ = PluginInternationalization('Sourcehut') +except: + _ = lambda x: x + + +def configure(advanced): + from supybot.questions import expect, anything, something, yn + conf.registerPlugin('Sourcehut', True) + + +Sourcehut = conf.registerPlugin('Sourcehut') diff --git a/contrib/ircbot/Sourcehut/local/__init__.py b/contrib/ircbot/Sourcehut/local/__init__.py new file mode 100644 index 0000000..e86e97b --- /dev/null +++ b/contrib/ircbot/Sourcehut/local/__init__.py @@ -0,0 +1 @@ +# Stub so local is a module, used for third-party modules diff --git a/contrib/ircbot/Sourcehut/plugin.py b/contrib/ircbot/Sourcehut/plugin.py new file mode 100644 index 0000000..a7d0b46 --- /dev/null +++ b/contrib/ircbot/Sourcehut/plugin.py @@ -0,0 +1,141 @@ +import email.header +import email.utils +import json +import mailbox +from urllib.parse import quote +from urllib.request import urlopen +import re +import traceback + +from supybot import callbacks, httpserver, ircmsgs, world +from supybot.ircutils import bold, italic, mircColor, underline + + +class Sourcehut(callbacks.Plugin): + """ + Supybot plugin to receive Sourcehut webhooks + """ + + def __init__(self, irc): + super().__init__(irc) + httpserver.hook("sourcehut", SourcehutServerCallback(self)) + + def die(self): + httpserver.unhook("sourcehut") + super().die() + + def announce(self, channel, message): + libera = world.getIrc("libera") + if libera is None: + print("error: no irc libera") + return + if channel not in libera.state.channels: + print(f"error: not in {channel} channel") + return + libera.sendMsg(ircmsgs.notice(channel, message)) + + +def decode_header(header: str) -> str: + if not header: + return "" + text = "" + for chunk, encoding in email.header.decode_header(header): + if isinstance(chunk, bytes): + chunk = chunk.decode(encoding or "us-ascii") + text += chunk + return text + + +class SourcehutServerCallback(httpserver.SupyHTTPServerCallback): + name = "Sourcehut" + defaultResponse = "Bad request\n" + + def __init__(self, plugin: Sourcehut): + super().__init__() + self.plugin = plugin + + SUBJECT = "[PATCH {prefix} v{version}] {subject}" + URL = "https://lists.sr.ht/{list[owner][canonicalName]}/{list[name]}" + CHANS = { + "#public-inbox": "##rjarry", + "#aerc-devel": "#aerc", + } + + def announce_patch(self, patchset): + subject = self.SUBJECT.format(**patchset) + url = self.URL.format(**patchset) + if not url.startswith("https://lists.sr.ht/~rjarry/"): + raise ValueError("unknown list") + url += "/patches/{id}".format(**patchset) + channel = f"#{patchset['list']['name']}" + channel = self.CHANS.get(channel, channel) + try: + submitter = patchset["submitter"]["canonicalName"] + except KeyError: + try: + submitter = patchset["submitter"]["name"] + except KeyError: + submitter = patchset["submitter"]["address"] + msg = f"{mircColor('received', 'light gray')} {bold(subject)}" + msg += f" from {italic(submitter)}: {underline(url)}" + self.plugin.announce(channel, msg) + + def announce_apply(self, mail): + channel = f"#{mail['list']['name']}" + channel = self.CHANS.get(channel, channel) + refs = [] + for header in mail['references']: + refs += header.split() + for ref in refs: + url = self.URL.format(**mail) + quote(f"/{ref}") + print(f"GET {url}/raw") + with urlopen(f"{url}/raw") as u: + msg = mailbox.Message(u.read()) + subject = re.sub(r"\s+", " ", decode_header(msg["subject"])) + if not subject.startswith("[PATCH"): + continue + for name, addr in email.utils.getaddresses([decode_header(msg["from"])]): + if name: + submitter = name + else: + submitter = addr + msg = f"{bold(mircColor('applied', 'green'))} {bold(subject)}" + msg += f" from {italic(submitter)}: {underline(url)}" + self.plugin.announce(channel, msg) + return + + def doPost(self, handler, path, form=None): + if hasattr(form, "decode"): + form = form.decode("utf-8") + print(f"POST {path} {form}") + try: + body = json.loads(form) + hook = body["data"]["webhook"] + if hook["event"] == "PATCHSET_RECEIVED": + self.announce_patch(hook["patchset"]) + handler.send_response(200) + handler.end_headers() + handler.wfile.write(b"") + return + + if hook["event"] == "EMAIL_RECEIVED": + if hook["email"]["patchset_update"] == ["APPLIED"]: + self.announce_apply(hook["email"]) + handler.send_response(200) + handler.end_headers() + handler.wfile.write(b"") + return + + raise ValueError(f"unsupported webhook: {hook}") + + except Exception as e: + traceback.print_exception(e) + handler.send_response(400) + handler.end_headers() + handler.wfile.write(b"Bad request\n") + + def log_message(self, format, *args): + pass + + +Class = Sourcehut diff --git a/contrib/ircbot/Sourcehut/setup.py b/contrib/ircbot/Sourcehut/setup.py new file mode 100644 index 0000000..11ba877 --- /dev/null +++ b/contrib/ircbot/Sourcehut/setup.py @@ -0,0 +1,3 @@ +from supybot.setup import plugin_setup + +plugin_setup('Sourcehut') diff --git a/contrib/ircbot/install-webhook.sh b/contrib/ircbot/install-webhook.sh new file mode 100755 index 0000000..d4db101 --- /dev/null +++ b/contrib/ircbot/install-webhook.sh @@ -0,0 +1,67 @@ +#!/bin/sh + +set -xe + +list="${1:-https://lists.sr.ht/~rjarry/aerc-devel}" +url="${2:-https://bot.diabeteman.com/sourcehut/}" + +hut lists webhook create "$list" --stdin -e patchset_received -u "$url" <<EOF +query { + webhook { + uuid + event + date + ... on PatchsetEvent { + patchset { + id + subject + version + prefix + list { + name + owner { + ... on User { + canonicalName + } + } + } + submitter { + ... on User { + canonicalName + } + ... on Mailbox { + name + address + } + } + } + } + } +} +EOF + +hut lists webhook create "$list" --stdin -e email_received -u "$url" <<EOF +query { + webhook { + uuid + event + date + ... on EmailEvent { + email { + id + subject + patchset_update: header(want: "X-Sourcehut-Patchset-Update") + references: header(want: "References") + list { + name + owner { + ... on User { + canonicalName + } + } + } + } + } + } +} +EOF diff --git a/contrib/ircbot/nginx.conf b/contrib/ircbot/nginx.conf new file mode 100644 index 0000000..f394ddf --- /dev/null +++ b/contrib/ircbot/nginx.conf @@ -0,0 +1,35 @@ +limit_req_zone $binary_remote_addr zone=aercbot:1m rate=1r/s; + +server { + listen 443 ssl; + listen [::]:443 ssl; + server_name bot.diabeteman.com; + + ssl_certificate /etc/dehydrated/certs/diabeteman.com/fullchain.pem; + ssl_certificate_key /etc/dehydrated/certs/diabeteman.com/privkey.pem; + + client_max_body_size 150K; + limit_req zone=aercbot burst=10 nodelay; + + location / { + allow 46.23.81.128/25; + allow 2a03:6000:1813::/48; + deny all; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_redirect off; + proxy_buffering off; + proxy_request_buffering off; + proxy_pass http://127.0.0.1:7777; + } +} + +server { + listen 80; + listen [::]:80; + server_name bot.diabeteman.com; + return 301 https://$host$request_uri; +} diff --git a/contrib/ircbot/supybot.conf b/contrib/ircbot/supybot.conf new file mode 100644 index 0000000..f7cff21 --- /dev/null +++ b/contrib/ircbot/supybot.conf @@ -0,0 +1,40 @@ +supybot.commands.allowShell = False +supybot.ident = aercbot +supybot.log.format = %(name)s: %(message)s +supybot.log.plugins.format = %(message)s +supybot.log.stdout.colorized = False +supybot.log.stdout.format = %(message)s +supybot.log.stdout.level = INFO +supybot.log.stdout.wrap = False +supybot.log.stdout = True +supybot.networks.libera.channels = #aerc +supybot.networks.libera.requireStarttls = False +supybot.networks.libera.sasl.password = ******************** +supybot.networks.libera.sasl.required = True +supybot.networks.libera.sasl.username = aercbot +supybot.networks.libera.servers = irc.libera.chat:6697 +supybot.networks.libera.ssl = True +supybot.networks = libera +supybot.nick.alternates = %s` %s_ +supybot.nick = aercbot +supybot.plugins.Channel.partMsg = KTHXBYE +supybot.plugins.Karma.allowSelfRating = False +supybot.plugins.Karma.allowUnaddressedKarma = True +supybot.plugins.Karma.decrementChars = -- +supybot.plugins.Karma.incrementChars = ++ +supybot.plugins.Karma.mostDisplay = 25 +supybot.plugins.Karma.onlyNicks = False +supybot.plugins.Karma.public = True +supybot.plugins.Karma.rankingDisplay = 3 +supybot.plugins.Karma.response = True +supybot.plugins.Karma.simpleOutput = True +supybot.plugins.Karma = True +supybot.plugins.Sourcehut.public = False +supybot.plugins.Sourcehut = True +supybot.servers.http.hosts4 = 127.0.0.1 +supybot.servers.http.hosts6 = ::1 +supybot.servers.http.keepAlive = False +supybot.servers.http.port = 7777 +supybot.servers.http.publicUrl = https://bot.diabeteman.com/ +supybot.servers.http.singleStack = True +supybot.user = aerc's IRC bot diff --git a/contrib/ircbot/supybot.service b/contrib/ircbot/supybot.service new file mode 100644 index 0000000..dd12785 --- /dev/null +++ b/contrib/ircbot/supybot.service @@ -0,0 +1,19 @@ +[Unit] +Description=IRC bot +After=network.target auditd.service + +[Service] +ExecStart=/usr/bin/supybot /var/lib/supybot/supybot.conf +User=supybot +Group=supybot +WorkingDirectory=/var/lib/supybot +ProtectHome=true +ProtectSystem=strict +ReadWritePaths=/var/lib/supybot /tmp +PrivateTmp=true +SyslogIdentifier=supybot +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/contrib/linters.go b/contrib/linters.go new file mode 100644 index 0000000..71895e7 --- /dev/null +++ b/contrib/linters.go @@ -0,0 +1,163 @@ +package main + +import ( + "go/ast" + "go/token" + "go/types" + "reflect" + "strings" + + "golang.org/x/tools/go/analysis" + "golang.org/x/tools/go/analysis/multichecker" +) + +type indirectCalls struct { + methods map[token.Pos]string + functions map[string]token.Pos +} + +var PanicAnalyzer = &analysis.Analyzer{ + Name: "panic", + Doc: "finds goroutines that do not initialize the panic handler", + Run: runPanic, + ResultType: reflect.TypeOf(&indirectCalls{}), +} + +var PanicIndirectAnalyzer = &analysis.Analyzer{ + Name: "panicindirect", + Doc: "finds functions called as goroutines that do not initialize the panic handler", + Run: runPanicIndirect, + Requires: []*analysis.Analyzer{PanicAnalyzer}, +} + +func runPanic(pass *analysis.Pass) (interface{}, error) { + var calls indirectCalls + + calls.methods = make(map[token.Pos]string) + calls.functions = make(map[string]token.Pos) + + for _, file := range pass.Files { + ast.Inspect(file, func(n ast.Node) bool { + g, ok := n.(*ast.GoStmt) + if !ok { + return true + } + + var block *ast.BlockStmt + + expr := g.Call.Fun + if e, ok := expr.(*ast.ParenExpr); ok { + expr = e.X + } + + switch e := expr.(type) { + case *ast.FuncLit: + block = e.Body + case *ast.SelectorExpr: + sel, ok := pass.TypesInfo.Selections[e] + if ok { + f, ok := sel.Obj().(*types.Func) + if ok { + calls.methods[f.Pos()] = f.Name() + } + } + case *ast.Ident: + block = inlineFuncBody(e) + if block == nil { + calls.functions[e.Name] = e.NamePos + } + } + + if block == nil { + return true + } + + if !isPanicHandlerInstall(block.List[0]) { + path := pass.Fset.File(block.Pos()).Name() + if !strings.HasSuffix(path, "_test.go") { + pass.Report(panicDiag(block.Pos())) + } + } + + return true + }) + } + + return &calls, nil +} + +func runPanicIndirect(pass *analysis.Pass) (interface{}, error) { + calls := pass.ResultOf[PanicAnalyzer].(*indirectCalls) + + for _, file := range pass.Files { + if strings.HasSuffix(file.Name.Name, "_test") { + continue + } + for _, decl := range file.Decls { + if f, ok := decl.(*ast.FuncDecl); ok { + if _, ok := calls.methods[f.Name.Pos()]; ok { + delete(calls.methods, f.Name.Pos()) + } else if _, ok := calls.functions[f.Name.Name]; ok { + delete(calls.functions, f.Name.Name) + } else { + continue + } + if !isPanicHandlerInstall(f.Body.List[0]) { + path := pass.Fset.File(f.Body.Pos()).Name() + if !strings.HasSuffix(path, "_test.go") { + pass.Report(panicDiag(f.Body.Pos())) + } + } + } + } + } + + return nil, nil +} + +func panicDiag(pos token.Pos) analysis.Diagnostic { + return analysis.Diagnostic{ + Pos: pos, + Category: "panic", + Message: "missing defer log.PanicHandler() as first statement", + } +} + +func inlineFuncBody(s *ast.Ident) *ast.BlockStmt { + if s.Obj == nil || s.Obj.Decl == nil { + return nil + } + d, ok := s.Obj.Decl.(*ast.AssignStmt) + if !ok { + return nil + } + for _, r := range d.Rhs { + if f, ok := r.(*ast.FuncLit); ok { + return f.Body + } + } + return nil +} + +func isPanicHandlerInstall(stmt ast.Stmt) bool { + d, ok := stmt.(*ast.DeferStmt) + if !ok { + return false + } + s, ok := d.Call.Fun.(*ast.SelectorExpr) + if !ok { + return false + } + i, ok := s.X.(*ast.Ident) + if !ok { + return false + } + return i.Name == "log" && s.Sel.Name == "PanicHandler" +} + +func main() { + multichecker.Main( + PanicAnalyzer, + PanicIndirectAnalyzer, + ) +} diff --git a/contrib/release.sh b/contrib/release.sh new file mode 100755 index 0000000..1008496 --- /dev/null +++ b/contrib/release.sh @@ -0,0 +1,136 @@ +#!/bin/sh + +set -e + +dry_run=false +case "$1" in +-n|--dry-run) + dry_run=true + ;; +esac + +changelog() { + title_prefix=$1 + width=$2 + first=true + wrap=cat + if [ -n "$width" ]; then + wrap="./wrap -r -w$width" + fi + for kind in Added Fixed Changed Deprecated; do + format="%(trailers:key=Changelog-$kind,unfold,valueonly)" + if git log --format="$format" $prev_tag.. | grep -q .; then + if [ "$first" = true ]; then + first=false + else + echo + fi + echo "$title_prefix $kind" + echo + git log --reverse --format="$format" $prev_tag.. | \ + sed -E '/^$/d; s/[[:space:]]+/ /; s/^/- /' | \ + $wrap + fi + done + format="%(trailers:key=Fixes,key=Closes,key=Implements,unfold,valueonly)" + if git log --format="$format" $prev_tag.. | grep -q "^https://todo.sr.ht"; then + if [ "$first" = true ]; then + first=false + else + echo + fi + echo "$title_prefix Closed Tickets" + echo + git log --format="$format" $prev_tag.. | + grep "^https://todo.sr.ht" | sort -u | + while read -r url; do + title=$(hut todo ticket show "$url" | head -n1) && + id=$(basename "$url") && + echo "- [#$id: $title]($url)" + done + fi +} + +echo "======= Determining next version..." +prev_tag=$(git describe --tags --abbrev=0) +next_tag=$(echo $prev_tag | awk -F. -v OFS=. '{$(NF-1) += 1; print}') +read -rp "next tag ($next_tag)? " n +if [ -n "$n" ]; then + next_tag="$n" +fi +tag_url="https://git.sr.ht/~rjarry/aerc/refs/$next_tag" + +if [ "$dry_run" = false ]; then + echo "======= Creating release commit..." + sed -i GNUmakefile -e "s/$prev_tag/$next_tag/g" + make wrap + { + echo + echo "## [$next_tag]($tag_url) - $(date +%Y-%m-%d)" + echo + changelog "###" 80 + } > .changelog.md + sed -i CHANGELOG.md -e '/^The format is based on/ r .changelog.md' + ${EDITOR:-vi} CHANGELOG.md + rm -f .changelog.md + git add GNUmakefile CHANGELOG.md + git commit -vesm "Release version $next_tag" + + echo "======= Creating tag..." + git -c core.commentchar='%' tag --edit --sign \ + -m "Release $next_tag highlights:" \ + -m "$(changelog '#' 72)" \ + -m "Thanks to all contributors!" \ + -m "~\$ contrib/git-stats.sh $prev_tag..$next_tag +$(contrib/git-stats.sh $prev_tag..)" \ + "$next_tag" + + echo "======= Pushing to remote..." + git push origin master "$next_tag" +fi + +echo "======= Sending release email..." + +email=$(mktemp aerc-release-XXXXXXXX.eml) +trap "rm -f -- $email" EXIT + +cat >"$email" <<EOF +From: $(git config user.name) <$(git config user.email)> +To: aerc-annouce <~rjarry/aerc-announce@lists.sr.ht> +Cc: aerc-devel <~rjarry/aerc-devel@lists.sr.ht> +Bcc: aerc <~sircmpwn/aerc@lists.sr.ht>, + $(git config user.name) <$(git config user.email)> +Reply-To: aerc-devel <~rjarry/aerc-devel@lists.sr.ht> +Subject: aerc $next_tag +User-Agent: aerc/$next_tag +Message-ID: <$(date +%Y%m%d%H%M%S).$(base32 -w12 < /dev/urandom | head -n1)@$(hostname)> +Content-Transfer-Encoding: 8bit +Content-Type: text/plain; charset=UTF-8 +MIME-Version: 1.0 + +Hi all, + +I am glad to announce the release of aerc $next_tag. + +$tag_url + +Release highlights: + +$(changelog '#') + +# Changed dependencies for downstream packagers + +$(contrib/depends-diff.py $prev_tag..) + +Thanks to all contributors! + +~\$ contrib/git-stats.sh $prev_tag..$next_tag + +$(contrib/git-stats.sh $prev_tag..) +EOF + +${EDITOR:-vi} "$email" + +if [ "$dry_run" = false ]; then + /usr/sbin/sendmail -t < "$email" +fi diff --git a/contrib/sendemail-validate b/contrib/sendemail-validate new file mode 100755 index 0000000..86b0653 --- /dev/null +++ b/contrib/sendemail-validate @@ -0,0 +1,78 @@ +#!/bin/sh + +# An example hook script to validate a patch (and/or patch series) before +# sending it via email. +# +# The hook should exit with non-zero status after issuing an appropriate +# message if it wants to prevent the email(s) from being sent. +# +# To enable this hook, rename this file to "sendemail-validate". +# +# By default, it will only check that the patch(es) can be applied on top of +# the default upstream branch without conflicts in a secondary worktree. After +# validation (successful or not) of the last patch of a series, the worktree +# will be deleted. +# +# The following config variables can be set to change the default remote and +# remote ref that are used to apply the patches against: +# +# sendemail.validateRemote (default: origin) +# sendemail.validateRemoteRef (default: HEAD) + +validate_cover_letter () { + file="$1" + true +} + +validate_patch () { + file="$1" + # Ensure that the patch applies without conflicts. + git am -3 "$file" || return + # Sign the patch if patatt is available. + case "$(git config --default false --get sendemail.runPatatt)" in + TRUE|True|true|yes|YES|Yes|Y|y|on|ON|On|1) + command -v patatt >/dev/null 2>&1 || return + patatt sign --hook "$file" || return 1 + ;; + esac +} + +validate_series () { + command -v gmake >/dev/null 2>&1 && make="gmake" || make="make" + $make validate +} + +# fallback for git 2.40 and older +: ${GIT_SENDEMAIL_FILE_COUNTER:=1} +: ${GIT_SENDEMAIL_FILE_TOTAL:=1} + +if test "$GIT_SENDEMAIL_FILE_COUNTER" = 1 +then + remote=$(git config --default origin --get sendemail.validateRemote) && + ref=$(git config --default master --get sendemail.validateRemoteRef) && + worktree=$(mktemp -d -t sendemail-validate.XXXXXXX) && + git worktree add -fd --checkout "$worktree" "refs/remotes/$remote/$ref" && + git config --replace-all sendemail.validateWorktree "$worktree" +else + worktree=$(git config --get sendemail.validateWorktree) +fi || { + echo "sendemail-validate: error: failed to prepare worktree" >&2 + exit 1 +} + +unset GIT_DIR GIT_WORK_TREE +cd "$worktree" && + +if grep -q "^diff --git " "$1" +then + validate_patch "$1" +else + validate_cover_letter "$1" +fi && + +if test "$GIT_SENDEMAIL_FILE_COUNTER" = "$GIT_SENDEMAIL_FILE_TOTAL" +then + git config --unset-all sendemail.validateWorktree && + trap 'git worktree remove -ff "$worktree"' EXIT && + validate_series +fi diff --git a/debian/NEWS b/debian/NEWS new file mode 100644 index 0000000..23da645 --- /dev/null +++ b/debian/NEWS @@ -0,0 +1,38 @@ +aerc (0.16.0-1) unstable; urgency=medium + + Aerc builtin filters path (/usr/libexec/aerc/filters) is now prepended to + the default system PATH to avoid conflicts with other installed binaries + which have the same name as aerc builtin filters (e.g. /usr/bin/colorize). + + Aerc now has a default style for most UI elements. The default styleset is + now empty. Existing stylesets will only override the default attributes if + they are set explicitly. To reset the default style and preserve existing + stylesets appearance, these two lines must be inserted at the beginning: + + *.default=true + *.normal=true + + -- Nilesh Patra <nilesh@debian.org> Sun, 10 Dec 2023 10:00:59 +0530 + +aerc (0.15.2-1) unstable; urgency=medium + + * Aerc's built-in filters have been moved from /usr/share/aerc/filters to + /usr/libexec/aerc/filters. The default exec PATH in aerc's context has + been modified to include all variations of the libexec subdirs. If your + configuration is using absolute paths, you must change it to either point + to new paths or to use filter names only. See aerc-config(5) for more + details. + * [ui].index-format setting has been replaced by index-columns. See + aerc-config(5) for more details. + * [statusline].render-format has been replaced by status-columns. See + aerc-config(5) for more details. + * [ui:subject...] contextual sections have been replaced by dynamic styleset + objects. See aerc-stylesets(7) for more details. + * [triggers] setting has been replaced by [hooks]. See aerc-config(5) for + more details. + * smtp-starttls setting in accounts.conf has been removed. All smtp:// + transports now assume STARTTLS and will fail if the server does not + support it. To disable STARTTLS, use smtp+insecure://. See aerc-smtp(5) + for more details. + + -- Robin Jarry <robin@jarry.cc> Mon, 19 Jun 2023 13:48:59 +0000 diff --git a/debian/changelog b/debian/changelog new file mode 100644 index 0000000..bb366b5 --- /dev/null +++ b/debian/changelog @@ -0,0 +1,234 @@ +aerc (0.20.0-2) unstable; urgency=medium + + * Add patch to fix temp file creation + + -- Nilesh Patra <nilesh@debian.org> Sat, 31 May 2025 23:09:24 +0530 + +aerc (0.20.0-1) unstable; urgency=medium + + * New upstream version 0.20.0 + + -- Robin Jarry <robin@jarry.cc> Sat, 25 Jan 2025 21:59:25 +0100 + +aerc (0.19.0-1) unstable; urgency=medium + + * Update upstream signing key + * New upstream version 0.19.0 + * Remove upstreamed patch + * Bump go-opt dependency version + * Update binary dependencies + + -- Robin Jarry <robin@jarry.cc> Sun, 19 Jan 2025 00:20:51 +0100 + +aerc (0.18.2-2) unstable; urgency=medium + + [ Robin Jarry ] + * d/patches: backport a fix for gpg signed messages encoding + * d/control: remove obsolete build dep to tcell + + [ Jonathan Dowland ] + * Move dante-client from Recommends to Suggests + + -- Robin Jarry <robin@jarry.cc> Mon, 26 Aug 2024 14:02:13 +0200 + +aerc (0.18.2-1) unstable; urgency=medium + + * [1d86d5a] New upstream version 0.18.2 + * [676751c] Bump Standards-Version to 4.7.0 (no changes needed) + * [d86920e] Tighten versioned depends on golang-sourcehut-rockorager-vaxis + + -- Nilesh Patra <nilesh@debian.org> Tue, 30 Jul 2024 12:33:07 +0900 + +aerc (0.18.0-1) unstable; urgency=medium + + * New upstream version 0.18.0 + * golang-sourcehut-rockorager-vaxis-dev to B-D + * Tighten B-D on golang-github-emersion-go-smtp-dev + + -- Nilesh Patra <nilesh@debian.org> Sun, 14 Jul 2024 00:38:32 +0530 + +aerc (0.17.0-1) unstable; urgency=medium + + * New upstream version 0.17.0 (Refresh patch) + (Closes: #1061505) + * Update Build-Depends + + -- Nilesh Patra <nilesh@debian.org> Sat, 24 Feb 2024 19:06:29 +0530 + +aerc (0.16.0-1) unstable; urgency=medium + + [ Nilesh Patra ] + * New upstream version 0.16.0 + * Drop backported patches + * Install GNUmakefile instead of Makefile in extra sources + * Add B-D on golang-sourcehut-rockorager-go-jmap + * Update minimum version of golang-github-emersion-go-message + in Build Depends + * Install contrib/carddav-query as extra source + * Add patch to fix blhc failure + * Fix extended description indentation and redundant + B-D versioning with cme + + [ Robin Jarry ] + * d/control: remove obsolete build dependencies + * d/NEWS: mention user facing changes for 0.16 + * Add runtime dependency to python3 for carddav-query + * Add suggest dependency to python3-vobject for show-ics-details.py + + -- Nilesh Patra <nilesh@debian.org> Sun, 10 Dec 2023 10:00:59 +0530 + +aerc (0.15.2-2) unstable; urgency=medium + + * Backport patch to fix deadlock in message channel + (Closes: #1040907) + * Simplify d/rules a bit + + -- Nilesh Patra <nilesh@debian.org> Sat, 15 Jul 2023 00:00:14 +0530 + +aerc (0.15.2-1) unstable; urgency=medium + + * New upstream version 0.15.2 + * Update Standards-Version to 4.6.2 + * Enable hardening build flags. + + -- Robin Jarry <robin@jarry.cc> Mon, 19 Jun 2023 15:01:20 +0000 + +aerc (0.14.0-1) unstable; urgency=medium + + * New upstream version 0.14.0 + + -- Robin Jarry <robin@jarry.cc> Fri, 06 Jan 2023 23:47:31 +0100 + +aerc (0.13.0-1) unstable; urgency=medium + + * New upstream version 0.13.0 + * d/control: update dependencies + + -- Robin Jarry <robin@jarry.cc> Thu, 20 Oct 2022 20:59:13 +0000 + +aerc (0.12.0-1) unstable; urgency=medium + + * New upstream version 0.12.0 + + -- Robin Jarry <robin@jarry.cc> Thu, 01 Sep 2022 10:54:15 +0200 + +aerc (0.11.0-1) unstable; urgency=medium + + [ Nilesh Patra ] + * Use DEB_VERSION_UPSTREAM instead of parsing from d/ch + * Disable reprotest in d/gitlab-ci.yml + + [ Robin Jarry ] + * d/rules: force version from changelog + * d/patches: remove disable-beep.diff + * New upstream version 0.11.0 + * d/patches: remove upstreamed patch + * d/control: update dependencies + + -- Robin Jarry <robin@jarry.cc> Fri, 15 Jul 2022 22:18:07 +0530 + +aerc (0.10.0-1) unstable; urgency=medium + + [ Nilesh Patra ] + * New upstream version 0.10.0 + * Remove d/p/do-not-append-ldflags.diff: Upstream adapted w/ our buildsystem + * Do not install show-ics-details.py + * Update d/gitlab-ci.yml: aerc is a leaf package and + would not break anything, use default gitlab-ci + * d/rules: Set GNUPGHOME to attempt fix build failure + + [ Robin Jarry ] + * d/control: add build-dependency and recommends on gnupg + + -- Nilesh Patra <nilesh@debian.org> Mon, 09 May 2022 22:58:53 +0000 + +aerc (0.9.0-1) unstable; urgency=medium + + * New upstream version 0.9.0 + * d/control: Update short description + * Add B-D on golang-github-gatherstars-com-jwz-dev, + golang-github-lithammer-fuzzysearch-dev and golang-github-xo-terminfo-dev + * Add patch to not append to ldflags, rather initialize + it so as to override when needed + + -- Nilesh Patra <nilesh@debian.org> Wed, 23 Mar 2022 19:01:04 +0530 + +aerc (0.8.2-1) unstable; urgency=medium + + * New upstream version 0.8.2 + * d/rules: Override LDFLAGS from + upstream, propagate debian-specific opts + * d/rules: Add contrib/aerc.desktop to DH_INSTALL_EXTRA + * Add patch to fix awk shebang in colorize script + + -- Nilesh Patra <nilesh@debian.org> Wed, 23 Feb 2022 00:48:08 +0530 + +aerc (0.7.1-1) unstable; urgency=medium + + * New upstream version 0.7.1 + * Upstream started signing tags + + Change d/watch to detect pgpmode + + Add d/u/signing-key.asc to verify upstream signatures + * Tighten B-D on golang-github-emersion-go-pgpmail-dev + * Add B-D on golang-github-protonmail-go-crypto-dev + * Drop use-x-crypto.diff: Use the protonmail crypto fork + + -- Nilesh Patra <nilesh@debian.org> Sat, 15 Jan 2022 19:25:17 +0530 + +aerc (0.6.0-1) unstable; urgency=medium + + * New upstream version 0.6.0 + * Change refs from sircmpwn => rjarry, update copyright, + since the project development would be going on a fork + * Drop d/p/backport-message-id.diff + * Refresh use-x-crypto.diff + * d/control: Update B-D + + -- Nilesh Patra <nilesh@debian.org> Mon, 22 Nov 2021 20:07:24 +0530 + +aerc (0.5.2-2) unstable; urgency=medium + + * Suppress executable-not-elf-or-script warning. + * d/control: Change section to mail. (Closes: #996819) + + -- Aloïs Micard <creekorful@debian.org> Wed, 20 Oct 2021 09:08:52 +0200 + +aerc (0.5.2-1) unstable; urgency=medium + + [ Aloïs Micard ] + * d/patches: + - Write patch to use new version of golang-github-emersion-go-message-dev. + - Write patch to use x-crypto instead of protonmail fork. + * d/rules: + - Install config/default_styleset. + + [ Nilesh Patra ] + * New upstream version 0.5.2 (Closes: #981066, #989151) + * Remove d/p/fix-installation-directories.diff for it has been fixed upstream + * Add d/u/metadata + * d/rules: s/make/$(MAKE)/g + * d/control + + Remove start with an article in Short Description + + Bump debhelper compatibility level to 13 + + Bump Standards-Version to 4.6.0 (no changes needed) + + Add Myself and Aloïs to Uploaders + + -- Nilesh Patra <nilesh@debian.org> Thu, 26 Aug 2021 20:07:20 +0530 + +aerc (0.4.0-1) unstable; urgency=medium + + * Release 0.4.0 + + -- Ben Fiedler <debian@services.bfiedler.ch> Mon, 17 Aug 2020 22:41:32 +0200 + +aerc (0.3.0-2) unstable; urgency=medium + + * Remove build paths embedded in final package (Closes: #963717) + + -- Ben Fiedler <debian@services.bfiedler.ch> Sun, 28 Jun 2020 20:19:02 +0200 + +aerc (0.3.0-1) unstable; urgency=medium + + * Initial release (Closes: #947962) + + -- Ben Fiedler <debian@services.bfiedler.ch> Tue, 21 Apr 2020 20:41:56 +0200 diff --git a/debian/control b/debian/control new file mode 100644 index 0000000..bc2003d --- /dev/null +++ b/debian/control @@ -0,0 +1,85 @@ +Source: aerc +Maintainer: Debian Go Packaging Team <team+pkg-go@tracker.debian.org> +Uploaders: Nilesh Patra <nilesh@debian.org>, + Robin Jarry <robin@jarry.cc>, + Aloïs Micard <creekorful@debian.org> +Section: mail +Testsuite: autopkgtest-pkg-go +Priority: optional +Build-Depends: debhelper-compat (= 13), + dh-golang, + gnupg, + golang-any, + golang-golang-x-sys-dev, + golang-golang-x-tools-dev, + golang-github-arran4-golang-ical-dev, + golang-github-danwakefield-fnmatch-dev, + golang-github-emersion-go-imap-dev (>= 1.2.0~), + golang-github-emersion-go-imap-sortthread-dev, + golang-github-emersion-go-maildir-dev (>= 0.4.1~), + golang-github-emersion-go-mbox-dev, + golang-github-emersion-go-message-dev (>= 0.17.0-1~), + golang-github-emersion-go-msgauth-dev, + golang-github-emersion-go-pgpmail-dev, + golang-github-emersion-go-sasl-dev, + golang-github-emersion-go-smtp-dev (>= 0.21.2-1~), + golang-github-fsnotify-fsnotify-dev, + golang-github-gatherstars-com-jwz-dev, + golang-github-go-ini-ini-dev (>= 1.52.0), + golang-github-lithammer-fuzzysearch-dev, + golang-github-mattn-go-isatty-dev, + golang-github-mattn-go-runewidth-dev, + golang-github-pkg-errors-dev, + golang-github-protonmail-go-crypto-dev, + golang-github-rivo-uniseg-dev, + golang-github-riywo-loginshell-dev, + golang-sourcehut-rjarry-go-opt-dev (>= 2.0.1-1~), + golang-sourcehut-rockorager-go-jmap-dev, + golang-github-stretchr-testify-dev, + golang-github-syndtr-goleveldb-dev, + golang-golang-x-oauth2-google-dev, + golang-sourcehut-rockorager-vaxis-dev (>= 0.10.3-1~), + libnotmuch-dev, + scdoc +Standards-Version: 4.7.0 +Vcs-Browser: https://salsa.debian.org/go-team/packages/aerc +Vcs-Git: https://salsa.debian.org/go-team/packages/aerc.git +Homepage: https://aerc-mail.org +Rules-Requires-Root: no +XS-Go-Import-Path: git.sr.ht/~rjarry/aerc + +Package: aerc +Architecture: any +Depends: ${misc:Depends}, + ${shlibs:Depends}, + python3, + w3m, +Recommends: gnupg2, + util-linux (>= 2.17~), +Suggests: notmuch, + python3-vobject, +Built-Using: ${misc:Built-Using} +Description: Pretty Good Email Client + aerc is an email client that runs in your terminal. It's highly efficient and + extensible, perfect for the discerning hacker. Some of its more interesting + features include: + . + * Editing emails in an embedded terminal tmux-style, allowing you to check on + incoming emails and reference other threads while you compose your replies + * Render HTML emails with an interactive terminal web browser, highlight + patches with diffs, and browse with an embedded less session + * Vim-style keybindings and ex-command system, allowing for powerful + automation at a single keystroke + * First-class support for working with git & email + * Open a new tab with a terminal emulator and a shell running for easy access + to nearby git repos for parallel work + * Support for multiple accounts, with IMAP, Maildir, Notmuch, Mbox and JMAP + backends. Along with IMAP, JMAP, SMTP, and sendmail transfer protocols. + * Asynchronous IMAP and JMAP support ensures the UI never gets locked up by + a flaky network. + * Efficient network usage - aerc only downloads the information which is + necessary to present the UI, making for a snappy and bandwidth-efficient + experience + * Email threading (with and/or without IMAP server support). + * PGP signing, encryption and verification using GNUpg. + * 100% free and open source software! diff --git a/debian/copyright b/debian/copyright new file mode 100644 index 0000000..976da23 --- /dev/null +++ b/debian/copyright @@ -0,0 +1,36 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: aerc +Upstream-Contact: Robin Jarry <~rjarry/aerc@lists.sr.ht> +Source: https://aerc-mail.org + +Files: * +Copyright: 2020 Drew Devault <sir@cmpwn.com> + 2021-2023 Robin Jarry <robin@jarry.cc> +License: Expat + +Files: debian/* +Copyright: 2020 Karthik <kskarthik@disroot.org> + 2020 Ben Fiedler <debian@services.bfiedler.ch> + 2021 Nilesh Patra <nilesh@debian.org> + 2021 Aloïs Micard <creekorful@debian.org> + 2022-2023 Robin Jarry <robin@jarry.cc> +License: Expat + +License: Expat + Permission is hereby granted, free of charge, to any person obtaining a + copy of this software and associated documentation files (the "Software"), + to deal in the Software without restriction, including without limitation + the rights to use, copy, modify, merge, publish, distribute, sublicense, + and/or sell copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following conditions: + . + The above copyright notice and this permission notice shall be included + in all copies or substantial portions of the Software. + . + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/debian/gbp.conf b/debian/gbp.conf new file mode 100644 index 0000000..3d450c2 --- /dev/null +++ b/debian/gbp.conf @@ -0,0 +1,3 @@ +[DEFAULT] +debian-branch = debian/sid +dist = DEP14 diff --git a/debian/gitlab-ci.yml b/debian/gitlab-ci.yml new file mode 100644 index 0000000..1e7946b --- /dev/null +++ b/debian/gitlab-ci.yml @@ -0,0 +1,7 @@ +--- +include: + - https://salsa.debian.org/salsa-ci-team/pipeline/raw/master/salsa-ci.yml + - https://salsa.debian.org/salsa-ci-team/pipeline/raw/master/pipeline-jobs.yml + +variables: + SALSA_CI_DISABLE_REPROTEST: 1 diff --git a/debian/patches/fix-blhc.patch b/debian/patches/fix-blhc.patch new file mode 100644 index 0000000..0ef7d71 --- /dev/null +++ b/debian/patches/fix-blhc.patch @@ -0,0 +1,11 @@ +--- a/GNUmakefile ++++ b/GNUmakefile +@@ -162,7 +162,7 @@ + define install_filter + ifneq ($(wildcard filters/$1.c),) + $1: filters/$1.c +- $$(CC) $$(CFLAGS) $$(LDFLAGS) -o $$@ $$< ++ $$(CC) $$(CFLAGS) $$(CPPFLAGS) $$(LDFLAGS) -o $$@ $$< + + all: $1 + endif diff --git a/debian/patches/fix-temp-file-creation.patch b/debian/patches/fix-temp-file-creation.patch new file mode 100644 index 0000000..1a1ba49 --- /dev/null +++ b/debian/patches/fix-temp-file-creation.patch @@ -0,0 +1,22 @@ +--- a/commands/msgview/open.go ++++ b/commands/msgview/open.go +@@ -5,6 +5,7 @@ + "io" + "mime" + "os" ++ "path" + "path/filepath" + + "git.sr.ht/~rjarry/aerc/app" +@@ -56,9 +57,9 @@ + app.PushError(err.Error()) + return + } +- filename := part.FileName() ++ filename := path.Base(part.FileName()) + var tmpFile *os.File +- if filename == "" { ++ if filename == "." { + extension := "" + if exts, _ := mime.ExtensionsByType(mimeType); len(exts) > 0 { + extension = exts[0] diff --git a/debian/patches/series b/debian/patches/series new file mode 100644 index 0000000..a7c8c4e --- /dev/null +++ b/debian/patches/series @@ -0,0 +1,2 @@ +fix-blhc.patch +fix-temp-file-creation.patch diff --git a/debian/rules b/debian/rules new file mode 100755 index 0000000..af47523 --- /dev/null +++ b/debian/rules @@ -0,0 +1,37 @@ +#!/usr/bin/make -f + +include /usr/share/dpkg/pkg-info.mk + +# Extra files required for build +export DH_GOLANG_INSTALL_EXTRA := GNUmakefile config contrib/aerc.desktop contrib/carddav-query doc filters stylesets templates + +# Installation paths +export DESTDIR=$(CURDIR)/debian/aerc +export PREFIX=/usr + +# Go options +export GO111MODULE=off +export GOPROXY=off +export GOCACHE=$(CURDIR)/_build/go-build +export GOPATH=$(CURDIR)/_build +export GOFLAGS=-tags=notmuch -buildmode=pie + +# Hardening +export DEB_BUILD_MAINT_OPTIONS = hardening=+all +DPKG_EXPORT_BUILDFLAGS = 1 +include /usr/share/dpkg/buildflags.mk + +%: + dh $@ --builddirectory=_build --buildsystem=golang --with=golang + +override_dh_auto_build: + $(MAKE) -C _build/src/git.sr.ht/~rjarry/aerc VERSION=$(DEB_VERSION_UPSTREAM) + +override_dh_auto_test: + mkdir -p debian/test-gnupg + chmod og-rx debian/test-gnupg + GNUPGHOME=${CURDIR}/debian/test-gnupg dh_auto_test + rm -rf debian/test-gnupg + +override_dh_auto_install: + $(MAKE) -C_build/src/git.sr.ht/~rjarry/aerc install VERSION=$(DEB_VERSION_UPSTREAM) diff --git a/debian/source/format b/debian/source/format new file mode 100644 index 0000000..163aaf8 --- /dev/null +++ b/debian/source/format @@ -0,0 +1 @@ +3.0 (quilt) diff --git a/debian/upstream/metadata b/debian/upstream/metadata new file mode 100644 index 0000000..3adfbb3 --- /dev/null +++ b/debian/upstream/metadata @@ -0,0 +1,7 @@ +--- +Archive: SourceHut +Bug-Database: https://todo.sr.ht/~rjarry/aerc +Bug-Submit: https://todo.sr.ht/~rjarry/aerc +Changelog: https://git.sr.ht/~rjarry/aerc/refs +Repository: https://git.sr.ht/~rjarry/aerc +Repository-Browse: https://git.sr.ht/~rjarry/aerc/tree diff --git a/debian/upstream/signing-key.asc b/debian/upstream/signing-key.asc new file mode 100644 index 0000000..8eaeefa --- /dev/null +++ b/debian/upstream/signing-key.asc @@ -0,0 +1,45 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mDMEYaOAcBYJKwYBBAHaRw8BAQdAv4gyJJyJ6Pa352i9dkChWv9InSp3Lcb0hliK +oI3AMKC0HFJvYmluIEphcnJ5IDxyb2JpbkBqYXJyeS5jYz6IkAQTFggAOBYhBNwH +GOMi4sdgXr3IMUaVfsCP0P6QBQJho4BwAhsDBQsJCAcCBhUKCQgLAgQWAgMBAh4B +AheAAAoJEEaVfsCP0P6Qj8oA/iH+GbIH6Eab3cqsASKA/3CQb93XmxqsI9hjsuqn +ba1gAP91eGGfZoNlmc1+vgNgV/20euYhia0phEE9KnEergcLA4kCMwQQAQoAHRYh +BMY9u+8hQnziSdvZawYSEpRGR6QRBQJjCLQTAAoJEAYSEpRGR6QRK/AP/ApUhbIh +dm+ExNnb+kPYvazq3O6nesPWcyOTXGoB/WUunGEom3zPfYcs/+7vmqjMvx+gdBIv +1zo8wvc5Q0eEAGt2dzCTHsycprCd9eyLWUwujfUzw9hzuE/9kyZY5lQkfVpwm0j6 +Ju6t/nRCNhrAB3H+EtHvu9QWCvucuNbbza00tMQKJw1cGVR40tA6IIdoHcmNgG9j +NoY7trts0dRJRvBCIG5uGpL8WC9aizxM+9hKOlpvdBS/+Ys19wRKw634iKyr+3R5 +LdqlU73HWSPS/0SLZmomffwRJnHDbIKrUtBRrEqpP/or+9pWndqBEjgJbUXZd9Eg +lNXoI7M3V2ZJFF0Xesd0u1BDrw9fQYs+uLBhOIrbf4gRb5qD/VZvuw+/M9HJleF+ +9d2Vj8GRYgIWEBSYIyHKlRI81Rzf6pCbbkWV+pBOg7a+yMZuporAKp7u3mTzJkwX +1m/AXNRjZ6x7MybuGpqvYzbiydgJerYS7M0Iy7jxX9GRO0ScLasUx/MZGYq9JrfN +hG31a5eu3uFjdbyjEIJ/mZLYA99nFT40k//0ubcajP1zg/BiOMGaoSfG0rPjBYCc +56gAPL+SzVXqyQQis/mjpfjIxV4xYWuEtjizQzzSvU85T6GPTPxFnJPhfcud9xTY +h7+yIc41jgSBQ0RTFGQ8Vnaw1antI5xfmvKUuDgEYaOAcBIKKwYBBAGXVQEFAQEH +QJKMaL7qmDBd6lb+6KCPmfO8hIYWxct8kdo8iceWftIZAwEIB4h+BBgWCAAmAhsM +FiEE3AcY4yLix2BevcgxRpV+wI/Q/pAFAmGjgbMFCQPCaEMACgkQRpV+wI/Q/pDD +owEA+3ZS2AzhG4Hk80oOUCcn7hv0NPHqaYa6vX2c/8l1QxUA/jkCAUuKGZSpvSbf +jmHlsElCPYOJIx+Ov92vyjP/eIoCiH4EGBYIACYCGwwWIQTcBxjjIuLHYF69yDFG +lX7Aj9D+kAUCZYNXnQUJB6I+LQAKCRBGlX7Aj9D+kADcAP4xTYnCaZVh+31h01+m +Hgc0rPZ/1Uje6yOcr29cLCDF1wEA31jMuITIcqP9hsKuPxTQ2H9NGrSfEGMnF93u +zVU3jgu4MwRho4FCFgkrBgEEAdpHDwEBB0BBwrifCsMD3W97/+Q9JM2VthuZ7tOA +OiIhluxCpE0r0Ih+BBgWCAAmFiEE3AcY4yLix2BevcgxRpV+wI/Q/pAFAmGjgUIC +GyAFCQPCZwAACgkQRpV+wI/Q/pAnhgEA4h0xjyK5dH/G+4OP1xdW2az8E9/Vnm1B +E2A5LQ6lz7EA/RX9XnK5hZgaDEStRnmthCZ2MpIbc430ox+SIBlLoFUOiH4EGBYI +ACYCGyAWIQTcBxjjIuLHYF69yDFGlX7Aj9D+kAUCZYNXtgUJB6I9dAAKCRBGlX7A +j9D+kBWCAQCoqcMewjVTYiX39cQVfL7LnNYfncyytRjEeZIuZIz3FAD9GePQJtbo +ZXNJuUp3Q6oAyqlvDcnxpzv47kH8Sicy/w+4MwRho4FcFgkrBgEEAdpHDwEBB0AY +FtKTC0QyGYBFChL78bax1FZ7YlKw52BgWEBCzAcalYj1BBgWCAAmFiEE3AcY4yLi +x2BevcgxRpV+wI/Q/pAFAmGjgVwCGwIFCQPCZwAAgQkQRpV+wI/Q/pB2IAQZFggA +HRYhBNi2qZB8OrcgQotgaGJxjg1mb8M1BQJho4FcAAoJEGJxjg1mb8M1JNwBAIkO +dCpYHyeU+Y/4WM0vu2Z+d50ShvcTjiBq9i1cIiF9AP938RY0dgvilD7rEAvWOn6t +BKif/vLFrv9OVMLqntNODTXSAP9oMeclstLRWPzBCYKGU7OGg9jTpdyFQVh0Qj0+ +1YAUbwD8Cm/L+fGHdblS0LYGWqJ3/LbmuT770uQlAL+6oON0SgKI9QQYFggAJgIb +AhYhBNwHGOMi4sdgXr3IMUaVfsCP0P6QBQJlg1fABQkHoj1kAIF2IAQZFggAHRYh +BNi2qZB8OrcgQotgaGJxjg1mb8M1BQJho4FcAAoJEGJxjg1mb8M1JNwBAIkOdCpY +HyeU+Y/4WM0vu2Z+d50ShvcTjiBq9i1cIiF9AP938RY0dgvilD7rEAvWOn6tBKif +/vLFrv9OVMLqntNODQkQRpV+wI/Q/pCUHwD9GMPJuy1lRhP/sb5unjv1KP3tC3Xc +hSlZsx6NK9KW+g4BAKvnon7V0E6MNLCWZQVh/obExwtI6MRQ89KSJuYfzrwD +=BaqK +-----END PGP PUBLIC KEY BLOCK----- diff --git a/debian/watch b/debian/watch new file mode 100644 index 0000000..2b5c50b --- /dev/null +++ b/debian/watch @@ -0,0 +1,4 @@ +version=4 +opts="mode=git, gitmode=full, pgpmode=gittag" \ +https://git.sr.ht/~rjarry/aerc \ +refs/tags/(\d[\d\.]+) diff --git a/doc/aerc-accounts.5.scd b/doc/aerc-accounts.5.scd new file mode 100644 index 0000000..3e50f5d --- /dev/null +++ b/doc/aerc-accounts.5.scd @@ -0,0 +1,321 @@ +AERC-ACCOUNTS(5) + +# NAME + +aerc-accounts - account configuration file format for *aerc*(1) + +# SYNOPSIS + +The _accounts.conf_ file is used for configuring each mail account used for +aerc. It is expected to be in your XDG config home plus _aerc_, which defaults +to _~/.config/aerc/accounts.conf_. This file must be kept secret, as it may +include your account credentials. An alternate file can be specified via the +_--accounts-conf_ command line argument, see *aerc*(1). + +If _accounts.conf_ does not exist, the *:new-account* configuration wizard will +be executed automatically on first startup. + +This file is written in the ini format where each *[section]* is the name of an +account you want to configure, and the keys & values in that section specify +details of that account's configuration. Global options may be configured by +placing them at the top of the file, before any account-specific sections. These +can be overridden for an account by specifying them again in the account +section. In addition to the options documented here, specific transports for +incoming and outgoing emails may have additional configuration parameters, +documented on their respective man pages. + +# CONFIGURATION + +Note that many of these configuration options are written for you, such as +*source* and *outgoing*, when you run the account configuration wizard +(*:new-account*). + +*archive* = _<folder>_ + Specifies a folder to use as the destination of the *:archive* command. + + Default: _Archive_ + +*check-mail* = _<duration>_ + Specifies an interval to check for new mail. Mail will be checked at + startup, and every interval. IMAP accounts will check for mail in all + unselected folders, and the selected folder will continue to receive + PUSH mail notifications. Maildir/Notmuch folders must use + *check-mail-cmd* in conjunction with this option. See *aerc-maildir*(5) + and *aerc-notmuch*(5) for more information. + + Setting this option to _0_ will disable *check-mail* + + Example: + *check-mail* = _5m_ + + Default: _0_ + +*copy-to* = _<folder1,folder2,folder3...>_ + Specifies a comma separated list of folders to copy sent mails to, + usually _Sent_. + + By default, the mail is copied to no folders; + +*copy-to-replied* = _true_|_false_ + In addition of *copy-to*, also copy replies to the folder in which the + replied message is. + + Default: _false_ + +*strip-bcc* = _true_|_false_ + Strip _Bcc_ headers before sending emails. This also affects local + copies of the sent messages (*copy-to* and *copy-to-replied*). + + Some email providers/backends automatically strip _Bcc_ headers before + dispatching the messages to recipients. Double check before setting this + to _false_ to avoid leaking any private information. + + Default: _true_ + +*default* = _<folder>_ + Specifies the default folder to open in the message list when aerc + configures this account. + + Default: _INBOX_ + +*folders* = _<folder1,folder2,folder3...>_ + Specifies the comma separated list of folders to display in the sidebar. + Names prefixed with _~_ are interpreted as regular expressions. + + By default, all folders are displayed. + +*folders-exclude* = _<folder1,folder2,folder3...>_ + Specifies the comma separated list of folders to exclude from the sidebar. + Names prefixed with _~_ are interpreted as regular expressions. + Note that this overrides anything from *folders*. + + By default, no folders are excluded. + +*enable-folders-sort* = _true_|_false_ + If _true_, folders are sorted, first by specified folders (see *folders-sort*), + then alphabetically. + + Default: _true_ + +*folders-sort* = _<folder1,folder2,folder3...>_ + Specifies a comma separated list of folders to be shown at the top of the + list in the provided order. Remaining folders will be sorted alphabetically. + +*folder-map* = _<file>_ + The folder map contains a one-to-one mapping of server folders to displayed + folder names. The *folder-map* file expects a + _<display-folder-name>_=_<server-folder-name>_[\*] + mapping per line (similar key=value syntax as for the *query-map* in notmuch). + The mappings are applied as they appear in the *folder-map*. + Supported backends: imap, maildir. + + Note that other account options such as *archive*, *default*, *copy-to*, + *postpone*, *folders*, *folders-exclude*, *folders-sort* need to be + adjusted if one of those folders is affected by a folder mapping. + + To apply the mapping to subfolders or folders with a similar prefix, + append '\*' to the server folder name. + + Examples: + + Remap a single folder: + ``` + Spam = [Gmail]/Spam + ``` + + Remap the folder and all of its subfolders: + ``` + G = [Gmail]\* + ``` + + Remove a prefix for all subfolders: + ``` + * = [Gmail]/\* + ``` + + Remap all subfolders and avoid a folder collision: + ``` + Archive/existing = Archive\* + Archive = OldArchive\* + ``` + +*from* = _<address>_ + The default value to use for the From header in new emails. This should be + an RFC 5322-compatible string, such as _Your Name <you@example.org>_. + +*aliases* = _<address1,address2,address3...>_ + All aliases of the current account. These will be used to fill in the From: + field. Make sure that your email server accepts this value, or for example + use *aerc-sendmail*(5) in combination with *msmtp*(1) and + *--read-envelope-from*. + + An alias can also use fnmatch-style wildcards in the address portion. These + wildcards can be useful for catch-all addresses. For example, the alias + _"Your Name" <\*@you.com>_ would ensure that when replying to emails addressed + to _hi@you.com_ and _contact@you.com_, the From: field is set to + _hi@you.com_ and _contact@you.com_, respectively. The name from the alias, + not from the matching address, is used. + +*use-envelope-from* = _true_|_false_ + Use the email envelope From header address instead of the *from* + configuration option when submitting messages. + + Default: _false_ + +*headers* = _<header1,header2,header3...>_ + Specifies the comma separated list of headers to fetch with the message. + + By default, all headers are fetched. If any headers are specified in this + list, aerc will append it to the following list of required headers: + + - date + - subject + - from + - sender + - reply-to + - to + - cc + - bcc + - in-reply-to + - message-id + - references + +*headers-exclude* = _<header1,header2,header3...>_ + Specifies the comma separated list of headers to exclude from fetching. + Note that this overrides anything from *headers*. + + By default, no headers are excluded. + +*outgoing* = _<uri>_ + Specifies the transport for sending outgoing emails on this account. It + should be a connection string, and the specific meaning of each component + varies depending on the protocol in use. See each protocol's man page for + more details: + + - *aerc-sendmail*(5) + - *aerc-smtp*(5) + +*outgoing-cred-cmd* = _<command>_ + Specifies an optional command that is run to get the outgoing account's + password. See each protocol's man page for more details. + +*outgoing-cred-cmd-cache* = _true_|_false_ + By default, the credentials returned by the command will be cached until + aerc is shut down. If set to _false_, *outgoing-cred-cmd* will be executed + every time an email is to be sent. + + Default: _true_ + +*pama-auto-switch* = _true_|_false_ + If _true_, the patch manager will automatically switch to an existing + project for the *:patch* command if the subject contains a '[PATCH <project>]' + segment. + + Default: _false_ + +*pgp-auto-sign* = _true_|_false_ + If _true_, all outgoing emails from this account will be signed (if a signing + key is available). + + Default: _false_ + +*pgp-attach-key* = _true_|_false_ + If _true_, attach the public signing key to signed outgoing emails. + + Default: _false_ + +*pgp-self-encrypt* = _true_|_false_ + If _true_, any outgoing encrypted email will be also encrypted for the sender + or the key specified in *pgp-key-id*. + + Default: _false_ + +*pgp-error-level* = _none_|_warn_|_error_ + The level of error to display when opportunistic encryption cannot be + performed. See *pgp-opportunistic-encryption*. + + Default: _warn_ + +*pgp-key-id* = _<key-id>_ + Specify the key id to use when signing a message. Can be either short or + long key id. If unset, aerc will look up the key by email. + +*pgp-opportunistic-encrypt* = _true_|_false_ + If _true_, any outgoing email from this account will be encrypted when all + recipients (including Cc and Bcc field) have a public key available in + the keyring. The level of error to display when a message can't be + encrypted can be configured with *pgp-error-level*. + + Default: _false_ + +*postpone* = _<folder>_ + Specifies the folder to save postponed messages to. + + Default: _Drafts_ + +*send-as-utc* = _true_|_false_ + Converts the timestamp of the Date header to UTC. + + Default: _false_ + +*send-with-hostname* = _true_|_false_ + Uses the local hostname in outgoing Message-Id headers instead of your + email address domain name. + + Default: _false_ + +*source* = _<uri>_ + Specifies the source for reading incoming emails on this account. This key + is required for all accounts. It should be a connection string, and the + specific meaning of each component varies depending on the protocol in use. + See each protocol's man page for more details: + + - *aerc-imap*(5) + - *aerc-jmap*(5) + - *aerc-maildir*(5) + - *aerc-notmuch*(5) + +*source-cred-cmd* = _<command>_ + Specifies an optional command that is run to get the source account's + password. See each protocol's man page for more details. + +*signature-file* = _<path>_ + Specifies the file to read in order to obtain the signature to be added + to emails sent from this account. + + Please note that by convention the Usenet signature style of two dashes, + followed by a space ("-- ") should be placed at the top of the signature + to separate content and signature. Aerc will add that delimiter if it is + not already present. + +*signature-cmd* = _<command>_ + Specifies the command to execute with _sh -c_ in order to obtain the + signature to be added to emails sent from this account. If the command + fails then *signature-file* is used instead. + +*trusted-authres* = _<host1,host2,host3...>_ + Comma-separated list of trustworthy hostnames from which the + Authentication Results header will be displayed. Entries can be regular + expressions. If you want to trust any host (e.g. for debugging), + use the wildcard _\*_. + +*subject-re-pattern* = _<regexp>_ + When replying to a message, this is the regular expression that will + be used to match the prefix of the original message's subject that has + to be removed, to create the subject line of the new message. + Typically, this will be used to avoid a repetition of the Re: + prefix in the subject header. The default will match known + translations for the common Re:. + + Default: _(?i)^((AW|RE|SV|VS|ODP|R): ?)+_ + +# SEE ALSO + +*aerc*(1) *aerc-config*(5) *aerc-imap*(5) *aerc-jmap*(5) *aerc-maildir*(5) +*aerc-notmuch*(5) *aerc-sendmail*(5) *aerc-smtp*(5) + +# AUTHORS + +Originally created by Drew DeVault and maintained by Robin Jarry who is assisted +by other open source contributors. For more information about aerc development, +see _https://sr.ht/~rjarry/aerc/_. diff --git a/doc/aerc-binds.5.scd b/doc/aerc-binds.5.scd new file mode 100644 index 0000000..e945ad0 --- /dev/null +++ b/doc/aerc-binds.5.scd @@ -0,0 +1,220 @@ +AERC-BINDS(5) + +# NAME + +aerc-binds - key bindings configuration file format for *aerc*(1) + +# SYNOPSIS + +The _binds.conf_ file is used for configuring keybindings used in the aerc +interactive client. It is expected to be in your XDG config home plus _aerc_, +which defaults to _~/.config/aerc/binds.conf_. If the file does not exist, the +built-in default will be installed. An alternate file can be specified via the +_--binds-conf_ command line argument, see *aerc*(1). + +This file is written in the ini format with key bindings defined as: + + *<key sequence>* = _<command>_ + +Where *<key sequence>* is the keystrokes pressed (in order) to invoke this +keybinding, and _<command>_ specifies keystrokes that aerc will simulate when +the keybinding is invoked. Generally this is used to execute commands, for +example: + + *rq* = _:reply -q<Enter>_ + +Pressing *r*, then *q*, will simulate typing in _:reply -q<Enter>_, and execute +*:reply -q* accordingly. It is also possible to invoke keybindings recursively +in a similar fashion. + +You may configure different keybindings for different contexts by writing them +into different *[sections]* of the ini file. + +# CONTEXTS + +The available contexts are: + +*[messages]* + keybindings for the message list + +*[view]* + keybindings for the message viewer + +*[view::passthrough]* + keybindings for the viewer, when in key passthrough mode + (toggled with *:toggle-key-passthrough*) + +*[compose]* + keybindings for the message composer + +*[compose::editor]* + keybindings for the composer, when the editor is focused + +*[compose::review]* + keybindings for the composer, when reviewing the email before it's sent + + To customize the description shown on the review screen, add a comment + (_" # "_) at the end of the keybinding. Example: + + p = :postpone<Enter> # I'll work on it later + + The order in which bindings are defined is preserved on the review + screen. If a non-default binding is not annotated it will be displayed + without any description. + + To hide a binding from the review screen, explicitly annotate it with + a _" # -"_ comment. Example: + + <C-e> = :encrypt<Enter> # - + +*[terminal]* + keybindings for terminal tabs + +You may also configure account specific key bindings for each context: + +*[context:account=*_AccountName_*]* + keybindings for this context and account, where _AccountName_ is a + regular expression that matches the account name you provided in _accounts.conf_. + +Folder and context-specific bindings can be configured for message lists: + +*[messages:folder=*_FolderName_*]*++ +*[view:folder=*_FolderName_*]*++ +*[compose:folder=*_FolderName_*]*++ +*[compose::editor:folder=*_FolderName_*]*++ +*[compose::review:folder=*_FolderName_*]* + keybindings under this section will be specific to the folder that + matches the regular expression _FolderName_. + Keybindings from a folder specifier will take precedence over account specifiers + +Examples: + +``` +[messages:account=Mailbox] +c = :cf path:mailbox/** and<space> + +[compose::editor:account=Mailbox2] + +[compose::editor:folder=aerc] +y = :send -t aerc + +[messages:folder=Drafts] +<Enter> = :recall<Enter> + +[messages:folder=Archive/\d+/.*] +gi = :cf Inbox<Enter> +... +``` + +You may also configure global keybindings by placing them at the beginning of +the file, before specifying any context-specific sections. + +Parent keybindings can be erased in the context ones by specifying an "empty" +binding: + +``` +[compose::review] +a = :attach<space> +d = :detach<space> + +[compose::review:account=no-attachments] +a = +d = +``` + +# SPECIAL OPTIONS + +In addition of user defined key sequences, the following special options are +available in each binding context: + +*$noinherit* = _true_|_false_ + If set to _true_, global keybindings will not be effective in this context. + + Default: _false_ + +*$ex* = _<key-stroke>_ + This can be set to a keystroke which will bring up the command input in this + context. + + Default: _:_ + +*$complete* = _<key-stroke>_ + This can be set to a keystroke which will trigger command completion in + this context for text inputs that support it. + + Default: _<tab>_ + + Note: automatic command completion is disabled when simulating + keystrokes and re-enabled at the end. When *[ui].completion-min-chars* + is set to _manual_ (see *aerc-config*(5)), it is possible to end + a keybinding with the completion key to explicitly display the + completion menu. E.g.: + + *o* = _:cf<space><tab>_ + +# SUPPORTED KEYS + +In addition to letters and some characters (e.g. *a*, *RR*, *gu*, *?*, *!*, +etc.), special keys may be specified in *<angle brackets>*. The syntax for +modified or special keys is: + + <C-A-S-key> + +Where C is control, A is alt, S is shift, and key is the named key or character. + +Valid key names are: + +[[ *Name* +:- *Description* +| *<space>* +: " " +| *<semicolon>* +: ; +| *<tab>* +: Tab +| *<enter>* +: Enter +| *<up>* +: Up arrow +| *<down>* +: Down arrow +| *<right>* +: Right arrow +| *<left>* +: Left arrow +| *<pgup>* +: Page Up +| *<pgdn>* +: Page Down +| *<home>* +: Home +| *<end>* +: End +| *<insert>* +: Insert +| *<delete>* +: Delete +| *<backspace>* +: Backspace +| *<exit>* +: Exit +| *<cancel>* +: Cancel +| *<print>* +: Print screen +| *<pause>* +: Pause +| *<backtab>* +: Shift+Tab +| *<esc>* +: Escape + +# SEE ALSO + +*aerc*(1) *aerc-config*(5) + +# AUTHORS + +Originally created by Drew DeVault and maintained by Robin Jarry who is assisted +by other open source contributors. For more information about aerc development, +see _https://sr.ht/~rjarry/aerc/_. diff --git a/doc/aerc-config.5.scd b/doc/aerc-config.5.scd new file mode 100644 index 0000000..0af8e67 --- /dev/null +++ b/doc/aerc-config.5.scd @@ -0,0 +1,1414 @@ +AERC-CONFIG(5) + +# NAME + +aerc-config - configuration file format for *aerc*(1) + +# SYNOPSIS + +There are three aerc config files: _aerc.conf_, _binds.conf_, and +_accounts.conf_. The last one must be kept secret, as it may include your +account credentials. We look for these files in your XDG config home plus +_aerc_, which defaults to _~/.config/aerc_. Alternate files can be specified via +command line arguments, see *aerc*(1). + +Examples of these config files are typically included with your installation of +aerc and are usually installed in _/usr/share/aerc_. + +Each file uses the ini format, and consists of sections with keys and values. +A line beginning with _#_ is considered a comment and ignored, as are empty +lines. New sections begin with _[section-name]_ on a single line, and keys and +values are separated with _=_. + +This manual page focuses on _aerc.conf_. _binds.conf_ is detailed in +*aerc-binds*(5) and _accounts.conf_ in *aerc-accounts*(5). + +_aerc.conf_ is used for configuring the general appearance and behavior of aerc. + +# GENERAL OPTIONS + +These options are configured in the *[general]* section of _aerc.conf_. + +*default-save-path* = _<path>_ + Used as a default path for save operations if no other path is specified. + +*pgp-provider* = _auto_|_gpg_|_internal_ + If set to _gpg_, aerc will use system gpg binary and keystore for all + crypto operations. If set to _internal_, the internal openpgp keyring + will be used. If set to _auto_, the system gpg will be preferred unless + the internal keyring already exists, in which case the latter will be + used. + + Default: _auto_ + +*use-terminal-pinentry* = _true_|_false_ + For terminal-based pinentry programs (such as _pinentry-tty_, + _pinentry-curses_ or _pinentry-vaxis_) to work properly with *aerc*(1), + set this to _true_. + + In some setups *aerc*(1) will not be able to determine the correct tty. + In those cases, you have to manually set _GPG_TTY_ to the output of *tty*(1) + before running *aerc*(1) as recommended by GnuPG for invoking a GPG-agent. Add + the following to your shell initialization scripts: + + ``` + GPG_TTY=$(tty) + export GPG_TTY + ``` + + Default: _false_ + +*unsafe-accounts-conf* = _true_|_false_ + By default, the file permissions of _accounts.conf_ must be restrictive + and only allow reading by the file owner (_0600_). Set this option to + _true_ to ignore this permission check. Use this with care as it may + expose your credentials. + + Default: _false_ + +*log-file* = _<path>_ + Output log messages to specified file. A path starting with _~/_ is + expanded to the user home dir. When redirecting aerc's output to a file + using _>_ shell redirection, this setting is ignored and log messages + are printed to stdout. + +*log-level* = _trace_|_debug_|_info_|_warn_|_error_ + Only log messages above the specified level to *log-file*. Supported + levels are: _trace_, _debug_, _info_, _warn_ and _error_. When + redirecting aerc's output to a file using _>_ shell redirection, this + setting is ignored and the log level is forced to _trace_. + + Default: _info_ + +*disable-ipc* = _true_|_false_ + Disable IPC entirely. Don't run commands (including _mailto:..._ and + _mbox..._) in an existing aerc instance and don't start an IPC server to + allow subsequent aerc instances to run commands in the current one. + + Default: _false_ + +*disable-ipc-mailto* = _true_ | _false_ + Don't run _mailto:..._ commands over IPC; start a new aerc instance with the + composer instead. + + Default: _false_ + +*disable-ipc-mbox* = _true_ | _false_ + Don't run _mbox:..._ commands over IPC; start a new aerc instance with the + mbox file instead. + + Default: _false_ + +*term* = _<TERM>_ + Set the $TERM environment variable used for the embedded terminal. + + Default: _xterm-256color_ + +*enable-osc8* = _true_|_false_ + Enable the embedded terminal to output OSC 8 (hyperlinks) escape + sequences. Not all terminal emulators handle OSC 8 sequences properly + and can produce confusing results, disable this setting if that occurs. + + Default: _false_ + +*enable-quake-mode* = _true_|_false_ + Enable Quake mode which consists of a persistent drop-down terminal + session that appears at the top of *aerc* and can be toggled on or off. + + Toggling is done with the F1 key. Do not use this key in your key + bindings in Quake mode. + + Default: _false_ + +*default-menu-cmd* = _<cmd>_ + Default shell command to use for *:menu*. This will be executed with + _sh -c_ and will run in an popover dialog. + + Any occurrence of _%f_ will be replaced by a temporary file path where + the command is expected to write output lines to be consumed by *:menu*. + Otherwise, the lines will be read from the command's standard output. + + Example: + *default-menu-cmd* = _fzf_ + +# UI OPTIONS + +These options are configured in the *[ui]* section of _aerc.conf_. + +*index-columns* = _<column1,column2,column3...>_ + Describes the format for each row in a mailbox view. This is a comma + separated list of column names with an optional align and width suffix. + After the column name, one of the _<_ (left), _:_ (center) or _>_ + (right) alignment characters can be added (by default, left) followed by + an optional width specifier. The width is either an integer representing + a fixed number of characters, or a percentage between _1%_ and _99%_ + representing a fraction of the terminal width. It can also be one of the + _\*_ (auto) or _=_ (fit) special width specifiers. Auto width columns + will be equally attributed the remaining terminal width. Fit width + columns take the width of their contents. If no width specifier is set, + _\*_ is used by default. + + Default: _flags:4,name<20%,subject,date>=_ + +*column-separator* = _"<separator>"_ + String separator inserted between columns. When a column width specifier + is an exact number of characters, the separator is added to it (i.e. the + exact width will be fully available for that column contents). + + Default: _" "_ + +*column-<name>* = _<go template>_ + Each name in *index-columns* must have a corresponding *column-<name>* + setting. All *column-<name>* settings accept golang text/template + syntax. + + By default, these columns are defined: + + ``` + column-flags = {{.Flags | join ""}} + column-name = {{index (.From | names) 0}} + column-subject = {{.ThreadPrefix}}{{.Subject}} + column-date = {{.DateAutoFormat .Date.Local}} + ``` + + See *aerc-templates*(7) for all available symbols and functions. + +*timestamp-format* = _<timeformat>_ + See time.Time#Format at https://godoc.org/time#Time.Format + + Default: _2006 Jan 02_ + +*this-day-time-format* = _<timeformat>_ + Index-only time format for messages that were received/sent today. + If this is empty, *timestamp-format* is used instead. + + Default: _15:04_ + +*this-week-time-format* = _<timeformat>_ + Index-only time format for messages that were received/sent within the + last 7 days. If this is empty, *timestamp-format* is used instead. + + Default: _Jan 02_ + +*this-year-time-format* = _<timeformat>_ + Index-only time format for messages that were received/sent this year. + If this is empty, *timestamp-format* is used instead. + + Default: _Jan 02_ + +*message-view-timestamp-format* = _<timeformat>_ + If set, overrides *timestamp-format* for the message view. + + Default: _2006 Jan 02, 15:04 GMT-0700_ + +*message-view-this-day-time-format* = _<timeformat>_ + If set, overrides *timestamp-format* in the message view for messages + that were received/sent today. + +*message-view-this-week-time-format* = _<timeformat>_ + If set, overrides *timestamp-format* in the message view for messages + that were received/sent within the last 7 days. + +*message-view-this-year-time-format* = _<timeformat>_ + If set, overrides *timestamp-format* in the message view for messages + that were received/sent this year. + +*sidebar-width* = _<int>_ + Width of the sidebar, including the border. Set to zero to disable the + sidebar. + + Default: _22_ + +*message-list-split* = _[<direction>] <size>_ + The default split layout for message list tabs. + + _<direction>_ is optional and defaults to _horizontal_. It can take one + of the following values: _h_, _horiz_, _horizontal_, _v_, _vert_, + _vertical_. + + _<size>_ is a positive integer representing the size (in terminal cells) + of the message list window. + + See *:split* in *aerc*(1) for more details. + +*empty-message* = _<string>_ + Message to display when viewing an empty folder. + + Default: _(no messages)_ + +*empty-dirlist* = _<string>_ + Message to display when no folders exists or are all filtered. + + Default: _(no folders)_ + +*empty-subject* = _<string>_ + Text to display in message list, when the subject is empty. + + Default: _(no subject)_ + +*mouse-enabled* = _true_|_false_ + Enable mouse events in the ui, e.g. clicking and scrolling with the mousewheel + + Default: _false_ + +*new-message-bell* = _true_|_false_ + Ring the bell when a new message is received. + + Default: _true_ + +*tab-title-account* = _<go_template>_ + The template to use for account tab titles. See *aerc-templates*(7) for + available field names. To conditionally show the unread count next to + the account name, set to: + + *tab-title-account* = {{.Account}} {{if .Unread}}({{.Unread}}){{end}} + + Default: _{{.Account}}_ + +*tab-title-composer* = _<go_template>_ + The template to use for composer tab titles. See *aerc-templates*(7) for + available field names. + + Default: _{{if .To}}to:{{index (.To | shortmboxes) 0}} {{end}}{{.SubjectBase}}_ + +*tab-title-viewer* = _<go_template>_ + The template to use for viewer tab titles. See *aerc-templates*(7) for + available field names. + + Default: _{{.Subject}}_ + +*pinned-tab-marker* = _"<string>"_ + Marker to show before a pinned tab's name. + + Default: _`_ + +*spinner* = _"<string>"_ + Animation shown while loading, split by *spinner-delimiter* (below) + + Examples: + - *spinner* = _"\-\_-,\_-\_"_ + - *spinner* = _'. , .'_ + - *spinner* = _"\,|,/,-"_ + + Default: _"[..] , [..] , [..] , [..] , [..], [..] , [..] , [..] "_ + +*spinner-delimiter* = _<string>_ + Spinner delimiter to split string into an animation + + Default: _,_ + +*spinner-interval* = _<duration>_ + The delay between each spinner frame + + Default: _200ms_ + +*sort* = _<criteria>_ + List of space-separated criteria to sort the messages by, see *:sort* + command in *aerc*(1) for reference. Prefixing a criterion with _-r_ + reverses that criterion. + + Example: + *sort* = _from -r date_ + +*dirlist-left* = _<go template>_ + Template for the left side of the directory list. + See *aerc-templates*(7) for all available fields and functions. + + Default: _{{.Folder}}_ + +*dirlist-right* = _<go template>_ + Template for the right side of the directory list. + See *aerc-templates*(7) for all available fields and functions. + + Default: _{{if .Unread}}{{humanReadable .Unread}}{{end}}_ + +*dirlist-delay* = _<duration>_ + Delay after which the messages are actually listed when entering + a directory. This avoids loading messages when skipping over folders + and makes the UI more responsive. If you do not want that, set it to + _0s_. + + Default: _200ms_ + +*dirlist-tree* = _true_|_false_ + Display the directory list as a foldable tree. + + Default: _false_ + +*dirlist-collapse* = _<int>_ + If *dirlist-tree* is enabled, set level at which folders are collapsed + by default. Set to _0_ to disable. + + Default: _0_ + +*next-message-on-delete* = _true_|_false_ + Moves to next message when the current message is deleted, archived, or moved. + + Default: _true_ + +*auto-mark-read* = _true_|_false_ + Set the _seen_ flag when a message is opened in the message viewer. + + Default: _true_ + +*completion-popovers* = _true_|_false_ + Shows potential auto-completions for text inputs in popovers. + + Default: _true_ + +*completion-delay* = _<duration>_ + How long to wait after the last input before auto-completion is triggered. + + Default: _250ms_ + +*completion-min-chars* = _<int>_ + The minimum required characters to allow auto-completion to be triggered + after *completion-delay*. + + Setting this to _manual_ disables automatic completion, leaving only the + manually triggered completion with the *$complete* key (see + *aerc-binds*(5) for more details). + + Default: _1_ + +*border-char-vertical* = _"<char>"_++ +*border-char-horizontal* = _"<char>"_ + Set stylable characters (via the *border* element) for vertical and + horizontal borders. + + Default: _" "_ + +*stylesets-dirs* = _<path1:path2:path3...>_ + The directories where the stylesets are stored. The config takes + a colon-separated list of dirs. If this is unset or if a styleset cannot + be found, the following paths will be used as a fallback in that order: + + ``` + ${XDG_CONFIG_HOME:-~/.config}/aerc/stylesets + ${XDG_DATA_HOME:-~/.local/share}/aerc/stylesets + /usr/local/share/aerc/stylesets + /usr/share/aerc/stylesets + ``` + +*styleset-name* = _<string>_ + The name of the styleset to be used to style the ui elements. The + stylesets are stored in the _stylesets_ directory in the config + directory. + + Default: _default_ + + Have a look at *aerc-stylesets*(7) as to how a styleset looks like. + +*icon-unencrypted* = _<string>_ + The icon to display for unencrypted mails. The status indicator is only + displayed if an icon is set. + +*icon-encrypted* = _<string>_ + The icon to display for encrypted mails. + + Default: _[e]_ + +*icon-signed* = _<string>_ + The icon to display for signed mails where the signature was + successfully validated. + + Default: _[s]_ + +*icon-signed-encrypted* = _<string>_ + The icon to display for signed and encrypted mails where the signature + was successfully verified. The combined icon is only used if set, + otherwise the signed and encrypted icons are displayed separately. + +*icon-unknown* = _<string>_ + The icon to display for signed mails which could not be verified due to + the key being unknown. + + Default: _[s?]_ + +*icon-invalid* = _<string>_ + The icon to display for signed mails where verification failed. + + Default: _[s!]_ + +*icon-attachment* = _<string>_ + The icon to display in *column-flags* when the message has an + attachment. + + Default: _a_ + +*icon-new* = _<string>_ + The icon to display in *column-flags* when the message is unread and + new. + + Default: _N_ + +*icon-old* = _<string>_ + The icon to display in *column-flags* when the message is unread and + old. + + Default: _O_ + +*icon-replied* = _<string>_ + The icon to display in *column-flags* when the message has been replied + to. + + Default: _r_ + +*icon-forwarded* = _<string>_ + The icon to display in *column-flags* when the message has been forwarded. + + Default: _f_ + +*icon-flagged* = _<string>_ + The icon to display in *column-flags* when the message is flagged. + + Default: _!_ + +*icon-marked* = _<string>_ + The icon to display in *column-flags* when the message is marked. + + Default: _\*_ + +*icon-draft* = _<string>_ + The icon to display in *column-flags* when the message is a draft. + + Default: _d_ + +*icon-deleted* = _<string>_ + The icon to display in *column-flags* when the message has been deleted. + + Default: _X_ + +*fuzzy-complete* = _true_|_false_ + When typing a command or option, the popover will now show not only the + items /starting/ with the string input by the user, but it will also show + instances of items /containing/ the string, starting at any position and + need not be consecutive characters in the command or option. + +*reverse-msglist-order* = _true_|_false_ + Reverses the order of the message list. By default, the message list is + ordered with the newest (highest UID) message on top. Reversing the + order will put the oldest (lowest UID) message on top. This can be + useful in cases where the backend does not support sorting. + + Default: _false_ + +*reverse-thread-order* = _true_|_false_ + Reverse display of the message threads. By default, the thread root is + displayed at the top of the tree with all replies below. The reverse + option will put the thread root at the bottom with replies on top. + + Default: _false_ + +*select-last-message* = _true_|_false_ + Positions the cursor on the last message in the message list (at the + bottom of the view) when opening a new folder. + + Default: _false_ + +*sort-thread-siblings* = _true_|_false_ + Sort the thread siblings according to the sort criteria for the messages. If + sort-thread-siblings is false, the thread siblings will be sorted based on + the message UID. This option is only applicable for client-side threading + with a backend that enables sorting. + + Default: _false_ + +*threading-enabled* = _true_|_false_ + Enable a threaded view of messages. If this is not supported by the + backend (IMAP server or notmuch), threads will be built by the client. + + Default: _false_ + +*force-client-threads* = _true_|_false_ + Force threads to be built client-side. Backends that don't support threading + will always build threads client side. + + Default: _false_ + +*threading-by-subject* = _true_|_false_ + If no References nor In-Reply-To headers can be matched to build client + side threads, fallback to similar subjects. + + Default: _false_ + +*client-threads-delay* = _<duration>_ + Delay of inactivity after which the client threads are rebuilt. Setting + this to _0s_ may introduce a noticeable lag when scrolling through the + message list. + + Default: _50ms_ + +*show-thread-context* = _true_|_false_ + Enable showing of thread context. Note: this is not supported by all + backends. + + Default: _false_ + +*msglist-scroll-offset* = _<int>_ + Set the scroll offset in number of lines from the top and bottom + of the message list. + + Default: _0_ + +*dialog-position* = _top_|_center_|_bottom_ + Set the position of popover dialogs such as the one from *:menu*, + *:envelope* or *:attach -m*. + + Default: _center_ + +*dialog-width* = _<width>_ + Set the width of popover dialogs as a percentage of the total width of + the window. The specified value should be between _10%_ and _100%_. + + Default: _50_ + +*dialog-height* = _<height>_ + Set the height of popover dialogs as a percentage of the total height of + the window. The specified value should be between _10%_ and _100%_. + + Default: _50_ + +*quake-terminal-height* = _<int>_ + Set the height of drop-down terminal as the number of lines from the + top. + + Default: _20_ + +## THREAD PREFIX CUSTOMIZATION + +You can fully customize the thread arrows appearance, which is defined by the +following configurable prefix parts: + +*thread-prefix-tip* = _<string>_ + Define the arrow head. + + Default: _">"_ + +*thread-prefix-indent* = _<string>_ + Define the arrow indentation. + + Default: _" "_ + +*thread-prefix-stem* = _<string>_ + Define the vertical extension of the arrow. + + Default: _"│"_ + +*thread-prefix-limb* = _<string>_ + Define the horizontal extension of the arrow. + + Default: _""_ + +*thread-prefix-folded* = _<string>_ + Define the folded thread indicator. + + Default: _"+"_ + +*thread-prefix-unfolded* = _<string>_ + Define the unfolded thread indicator. + + Default: _""_ + +*thread-prefix-first-child* = _<string>_ + Define the first child connector. + + Default: _""_ + +*thread-prefix-has-siblings* = _<string>_ + Define the connector used if the message has siblings. + + Default: _├─_ + +*thread-prefix-lone* = _<string>_ + Define the connector used if the message has no parents and no children. + + Default: _""_ + +*thread-prefix-orphan* = _<string>_ + Define the connector used if the message has no parents and has children. + + Default: _""_ + +*thread-prefix-last-sibling* = _<string>_ + Define the connector for the last sibling. + + Default: _└─_ + +*thread-prefix-last-sibling-reverse* = _<string>_ + Define the connector for the last sibling in reversed threads. + + Default: _┌─_ + +*thread-prefix-dummy* = _<string>_ + Define the connector for the dummy head. + + Default: _┬─_ + +*thread-prefix-dummy-reverse* = _<string>_ + Define the connector for the dummy head in reversed threads. + + Default: _┴─_ + +*thread-prefix-first-child-reverse* = _<string>_ + + Define the arrow appearance by selecting the first child connector in + reversed threads. + + Default: _""_ + +*thread-prefix-orphan-reverse* = _<string>_ + Customize the reversed threads arrow appearance by selecting the + connector used if the message has no parents and has children. + + Default: _""_ + + +Default settings (mutt-style): + + ``` + [PATCH aerc v5] ui: allow thread arrow customisation + ├─>[aerc/patches] build success + ├─>Re: [PATCH aerc v5] ui: allow thread arrow customisation + ├─+ + └─> + ├─> + │ ├─> + │ └─> + │ └─> + └─> + ``` + +More compact, rounded threads that are also fold-aware: + + ``` + ┌[PATCH aerc v5] ui: allow thread arrow customisation + ├─[aerc/patches] build success + ├─Re: [PATCH aerc v5] ui: allow thread arrow customisation + ├+ + ╰┬ + ├┬ + │├─ + │╰┬ + │ ╰─ + ╰─ + ``` + +``` +thread-prefix-tip = "" +thread-prefix-indent = "" +thread-prefix-stem = "│" +thread-prefix-limb = "─" +thread-prefix-folded = "+" +thread-prefix-unfolded = "" +thread-prefix-first-child = "┬" +thread-prefix-has-siblings = "├" +thread-prefix-orphan = "┌" +thread-prefix-dummy = "┬" +thread-prefix-lone = " " +thread-prefix-last-sibling = "╰" +``` + +## CONTEXTUAL UI CONFIGURATION + +The UI configuration can be specialized for accounts, specific mail +directories and message subjects. The specializations are added using +contextual config sections based on the context. + +The contextual UI configuration is merged to the base UiConfig in the +following order: *Base UIConfig > Account Context > Folder Context*. + +*[ui:account=*_AccountName_*]* + Adds account specific configuration with the account name. + +*[ui:folder=*_FolderName_*]* + Add folder specific configuration with the folder name. + +*[ui:folder~*_Regex_*]* + Add folder specific configuration for folders whose names match the regular + expression. + +Example: +``` +[ui:account=Work] +sidebar-width=... + +[ui:folder=Sent] +index-format=... + +[ui:folder~Archive/\d+/.*] +index-format=... +``` + +# STATUSLINE + +These options are configured in the *[statusline]* section of _aerc.conf_. + +*status-columns* = _<column1,column2,column3...>_ + Describes the format for the statusline. This is a comma separated list + of column names with an optional align and width suffix. See + *[ui].index-columns* for more details. + + To completely mute the statusline (except for push notifications), + explicitly set *status-columns* to an empty string: + + status-columns= + + Default: _left<\*,center>=,right>\*_ + +*column-separator* = _"<separator>"_ + String separator inserted between columns. See *[ui].column-separator* + for more details. + + Default: _" "_ + +*column-<name>* = _<go template>_ + Each name in *status-columns* must have a corresponding *column-<name>* + setting. All *column-<name>* settings accept golang text/template + syntax. + + By default, these columns are defined: + + ``` + column-left = [{{.Account}}] {{.StatusInfo}} + column-center = {{.PendingKeys}} + column-right = {{.TrayInfo}} | {{cwd}} + ``` + + See *aerc-templates*(7) for all available symbols and functions. + +*separator* = _"<string>"_ + Specifies the separator between grouped statusline elements (e.g. for + the _{{.ContentInfo}}_, _{{.TrayInfo}}_ and _{{.StatusInfo}}_ in + *column-<name>*). + + Default: _" | "_ + +*display-mode* = _text_|_icon_ + Defines the mode for displaying the status elements. + + Default: _text_ + +# VIEWER + +These options are configured in the *[viewer]* section of _aerc.conf_. + +*pager* = _<command>_ + Specifies the pager to use when displaying emails. Note that some filters + may add ANSI escape sequences to add color to rendered emails, so you may + want to use a pager which supports ANSI. + + Default: _less -Rc_ + +*alternatives* = _<mime,types>_ + If an email offers several versions (multipart), you can configure which + mimetype to prefer. For example, this can be used to prefer plaintext over + HTML emails. + + Default: _text/plain,text/html_ + +*header-layout* = _<header|layout,list...>_ + Defines the default headers to display when viewing a message. To display + multiple headers in the same row, separate them with a pipe, e.g. _From|To_. + Rows will be hidden if none of their specified headers are present in the + message. + + Notmuch tags can be displayed by adding Labels. + + Authentication information from the Authentication-Results header can be + displayed by adding _DKIM_, _SPF_ or _DMARC_. To show more information + than just the authentication result, append a plus sign (*+*) to the header name + (e.g. _DKIM+_). + + Default: _From|To,Cc|Bcc,Date,Subject_ + +*show-headers* = _true_|_false_ + Default setting to determine whether to show full headers or only parsed + ones in message viewer. + + Default: _false_ + +*always-show-mime* = _true_|_false_ + Whether to always show the mimetype of an email, even when it is just a single part. + + Default: _false_ + +*max-mime-height* = _height_ + Define the maximum height of the mimetype switcher before a scrollbar is + used. The height of the mimetype switcher is restricted to half of the display height. + If the provided value for the height is zero, the number of parts will + be used as the height of the type switcher. + + Default: 0 + +*parse-http-links* = _true_|_false_ + Parses and extracts http links when viewing a message. Links can then be + accessed with the *open-link* command. + + Default: _true_ + +# COMPOSE + +These options are configured in the *[compose]* section of _aerc.conf_. + +*editor* = _<command>_ + Specifies the command to run the editor with. It will be shown in an + embedded terminal, though it may also launch a graphical window if the + environment supports it. + + The following variables are defined in the editor's environment: + + *AERC_ACCOUNT* + the name of the current account + *AERC_ADDRESS_BOOK_CMD* + the _address-book-cmd_ specified for the current account in + _accounts.conf_ + + Defaults to *$EDITOR*, or *vi*(1). + +*header-layout* = _<header|layout,list...>_ + Defines the default headers to display when composing a message. To display + multiple headers in the same row, separate them with a pipe, e.g. _To|From_. + + Default: _To|From,Subject_ + +*edit-headers* = _true_|_false_ + Edit headers directly into the text editor instead of having separate UI + text inputs. + + When this is set to _true_, the *:cc*, *:bcc* and *:header* commands do + not work, editing email headers are left to the text editor. + *address-book-cmd* is not supported and address completion is left to + the editor itself. *header-layout* is ignored. + + Default: _false_ + +*focus-body* = _true_|_false_ + Sets focus to the email body when the composer window opens. + + Default: _false_ + +*address-book-cmd* = _<command>_ + Specifies the command to be used to tab-complete email addresses. Any + occurrence of _%s_ in the *address-book-cmd* will be replaced with anything + the user has typed after the last comma. + + The command must output the completions to standard output, one completion + per line. Each line must be tab-delimited, with an email address occurring as + the first field. Only the email address field is required. The second field, + if present, will be treated as the contact name. Additional fields are + ignored. + + This parameter can also be set per account in _accounts.conf_. + + Example with *carddav-query*(1): + *address-book-cmd* = _carddav-query %s_ + + Example with *khard*(1): + *address-book-cmd* = _khard email --remove-first-line --parsable %s_ + +*file-picker-cmd* = _<command>_ + Specifies the command to be used to select attachments. Any occurrence of + _%s_ in the *file-picker-cmd* will be replaced with the argument _<arg>_ + to *:attach -m* _<arg>_. Any occurrence of _%f_ will be replaced by the + location of a temporary file, from which aerc will read the selected files. + + If _%f_ is not present, the command must output the selected files to + standard output, one file per line. If it is present, then aerc does not + capture the standard output and instead reads the files from the temporary + file which should have the same format. + + Examples: + *file-picker-cmd* = _fzf --multi --query=%s_ + *file-picker-cmd* = _ranger --choose-files=%f_ + +*reply-to-self* = _true_|_false_ + If set to _false_, do not mail yourself when replying (e.g., if replying + to emails previously sent by yourself, address your replies to the + original To and Cc). + + Default: _true_ + +*empty-subject-warning* = _true_|_false_ + Warn before sending an email with an empty subject. + + Default: _false_ + +*no-attachment-warning* = _<regexp>_ + Specifies a regular expression against which an email's body should be + tested before sending an email with no attachment. If the regexp + matches, aerc will warn you before sending the message. Leave empty to + disable this feature. + + Uses Go's regexp syntax, documented at https://golang.org/s/re2syntax. + The _(?im)_ flags are set by default (case-insensitive and multi-line). + + Example: + *no-attachment-warning* = _^[^>]\*attach(ed|ment)_ + +*format-flowed* = _true_|_false_ + When set, aerc will generate _Format=Flowed_ bodies with a content type + of _"text/plain; Format=Flowed"_ as described in RFC3676. This format is + easier to handle for some mailing software, and generally just looks + like ordinary text. To actually make use of this format's features, + you'll need support in your editor. + + Default: _false_ + +*lf-editor* = _true_|_false_ + By default, aerc will use RFC2822 standard _\\r\\n_ (CRLF) line breaks + when composing messages. Use this option for text editors that only + support non-standard _\\n_ (LF) line breaks. + + Default: _false_ + +# MULTIPART CONVERTERS + +Converters allow generating _multipart/alternative_ messages by converting the +main _text/plain_ body into any other text MIME type with the *:multipart* +command. Only exact MIME types are accepted. The commands are invoked with +_sh -c_ and are expected to output valid UTF-8 text. + +Only _text/<subtype>_ MIME parts can be generated. The _text/plain_ MIME type is +reserved and cannot be generated. You still need to write your emails by hand in +your favorite text editor. + +Converters are configured in the *[multipart-converters]* section of +_aerc.conf_. + +Example: + +``` +[multipart-converters] +text/html=pandoc -f markdown -t html --standalone +``` + +Obviously, this requires that you write your main _text/plain_ body using the +markdown syntax. Also, mind that some mailing lists reject emails that contain +_text/html_ alternative parts. Use this feature carefully and when possible, +avoid using it at all. + +# FILTERS + +Filters are a flexible and powerful way of handling viewing parts of an opened +message. When viewing messages aerc will show the list of available message +parts and their MIME type at the bottom, but unless a filter is defined for +a specific MIME type, it will only show a menu with a few options (allowing you +to open the part in an external program, save it to disk or pipe it to a shell +command). Configuring a filter will allow viewing the output of the filter in +the configured *pager* in aerc's built-in terminal. + +Filters are configured in the *[filters]* section of *aerc.conf*. The first +filter which matches the part's MIME type will be used, so order them from most +to least specific. You can also match on non-MIME types, by prefixing with the +header to match against (non-case-sensitive) and a comma, e.g. _subject,text_ +will match a subject which contains _text_. Use _header,~regex_ to match +against a _regex_. Using _.filename_ instead of a header will match against the +filename of an attachment. + +Note that aerc will pipe the content into the configured filter program, so +filters need to be able to read from standard input. Many programs support +reading from stdin by putting _-_ instead of a path to a file. You can also +chain together multiple filters by piping with _|_. + +Some filter commands may require interactive user input. If a filter command +starts with an exclamation mark _!_, the configured *pager* will *not* be used. +Instead, the filter command will be executed as the main process in the embedded +terminal of the part viewer. The filter command standard input, output and error +will be set to the terminal TTY. The filter is expected to implement its own +paging. + +aerc ships with some default filters installed in the libexec directory (usually +_/usr/libexec/aerc/filters_). Note that these may have additional dependencies +that aerc does not have alone. + +The filter commands are invoked with _sh -c command_. The following folders are +prepended to the system *$PATH* to allow referencing filters from their name only. + +``` +${XDG_CONFIG_HOME:-~/.config}/aerc/filters +~/.local/libexec/aerc/filters +${XDG_DATA_HOME:-~/.local/share}/aerc/filters +$PREFIX/libexec/aerc/filters +$PREFIX/share/aerc/filters +/usr/libexec/aerc/filters +/usr/share/aerc/filters +``` + +If you want to run a program in your default *$PATH* which has the same +name as a builtin filter (e.g. _/usr/bin/colorize_), use its absolute path. + +The following variables are defined in the filter command environment: + +*AERC_MIME_TYPE* + the part MIME type/subtype +*AERC_FORMAT* + the part content type format= parameter (e.g. format=flowed) +*AERC_FILENAME* + the attachment filename (if any) +*AERC_SUBJECT* + the message Subject header value +*AERC_FROM* + the message From header value + +Note that said email body is converted into UTF-8 before being passed to +filters. + +If *show-headers* is enabled, only the currently viewed part body is piped into +the filter command. A special _.headers_ filter command can be defined to post +process the full headers. + +## EXAMPLES + +_text/plain_ + Color some things, e.g. quotes, git diffs, links, etc.: + + ``` + text/plain=colorize + ``` + + The built-in _colorize_ filter can be configured in the *[viewer]* + section of styleset files. See *aerc-stylesets*(7). + + Wrap long lines at 100 characters, while not messing up nested quotes. + Handles format=flowed emails properly: + + ``` + text/plain=wrap -w 100 | colorize + ``` + +_from,<sender>_ + Another example of hard wrapping lines of emails sent by a specific + person. Explicitly reflow all paragraphs instead of only wrapping long + lines. This may break manual formatting in some messages: + + ``` + from,thatguywhoneverhardwrapshismessages=wrap -r -w 72 | colorize + ``` + +_subject,~<regexp>_ + Use rainbow coloring with *lolcat*(1) for emails sent by software + forges: + + ``` + subject,~Git(hub|lab)=lolcat -f + ``` + +_text/html_ + Render html to a more human readable version and colorize: + + ``` + text/html=html | colorize + ``` + + Use pandoc to output plain text: + + ``` + text/html=pandoc -f html -t plain + ``` + + Use w3m internal pager to interactively view an HTML part with coloring: + + ``` + text/html=! w3m -I UTF-8 -T text/html + ``` + + If *img2sixel*(1) is installed and your terminal emulator supports + displaying images using the sixel protocol, use the builtin + *html-unsafe* filter (to allow external URLs to be loaded) in + interactive mode with explicit sixel inline images enabled. + + ``` + text/html=! html-unsafe -sixel + ``` + +_text/calendar_ + Parse calendar invites: + + ``` + text/calendar=calendar + ``` + +_text/\*_ + Catch any other type of text that did not have a specific filter and + use *bat*(1) to color these: + + ``` + text/\*=bat -fP --file-name="$AERC_FILENAME" --style=plain + ``` + +_.headers_ + Colorize email headers when *show-headers* is _true_. + + ``` + .headers=colorize + ``` + +_message/delivery-status_ + When not being able to deliver the provider might send such emails: + + ``` + message/delivery-status=colorize + ``` + +_message/rfc822_ + When getting emails as attachments, e.g. on some mailing lists digest + format is sending an email with all the digest emails as attachments. + Requires *caeml*(1) to be on *PATH*: + + ``` + message/rfc822=caeml | colorize + ``` + + https://github.com/ferdinandyb/caeml + +_application/mbox_ + Emails as attachments in the mbox format. For example aerc can also + create an mbox from messages with the *:pipe* command. Requires + *catbox*(1) and *caeml*(1) to be on *PATH*: + + ``` + application/mbox=catbox -c caeml | colorize + ``` + + https://github.com/konimarti/catbox + +_application/pdf_ + Render pdf to text and rewrap at 100 character width. Requires + *pdftotext*(1) to be on *PATH*: + + ``` + application/pdf=pdftotext - -l 10 -nopgbrk -q - | fmt -w 100 + ``` + + https://www.xpdfreader.com/pdftotext-man.html + +_image/\*_ + + This is a tricky topic. It's possible to display images in a terminal, + but for high resolution images the terminal you are using either needs + to support sixels or the kitty terminal graphics protocol. The built-in + terminal emulator of aerc (via the TUI library Vaxis) supports both. + Furthermore if you don't set any filter for images, Vaxis will figure + out what your terminal emulator supports and either use sixels, kitty + graphics, or fall back to a pixelated half-block. You can turn this + feature off, by setting a filter that is essentially no-op. + + You can still set a specific filter, e.g *catimg*(1): + + ``` + image/\*=catimg -w$(tput cols) - + ``` + +_.filename,~<regexp>_ + Match <regexp> against the filename of an attachment, e.g. to split + csv-s into columns: + + ``` + .filename,~.*\.csv=column -t --separator="," + ``` + +See the wiki at https://man.sr.ht/~rjarry/aerc/ for more examples and possible +customizations of the built-in filters. + +# OPENERS + +Openers allow you to specify the command to use for the *:open* and *:open-link* +actions on a per-MIME-type basis. The *:open-link* URL scheme is used to +determine the MIME type as follows: _x-scheme-handler/<scheme>_. They are +configured in the *[openers]* section of _aerc.conf_. + +_{}_ is expanded as the temporary filename or URL to be opened with proper shell +quoting. If it is not encountered in the command, the filename/URL will be +appended to the end of the command. The command will then be executed with +_sh -c_. + +Like *[filters]*, openers support basic shell globbing. The first opener which +matches the part's MIME type (or URL scheme handler MIME type) will be used, so +order them from most to least specific. + +Example: + +``` +[openers] +x-scheme-handler/irc=hexchat +x-scheme-handler/http\*=printf '%s' {} | wl-copy +text/html=surf -dfgms +text/plain=gvim {} +125 +message/rfc822=thunderbird +``` + +# HOOKS + +Hooks are triggered whenever the associated event occurs. The commands are run +in a shell environment with information added to environment variables. + +They are configured in the *[hooks]* section of aerc.conf. + +*aerc-startup* = _<command>_ + Executed when aerc is started. The hook is executed as soon as the UI is + initialized and does not wait for all accounts to be fully loaded. + + Variables: + + - *AERC_VERSION* + - *AERC_BINARY* + + Example: + + *aerc-startup* = _aerc :terminal calcurse && aerc :next-tab_ + +*mail-received* = _<command>_ + Executed when new mail is received in the selected folder. This will + only work reliably with maildir and some imap servers. + + Variables: + + - *AERC_ACCOUNT* + - *AERC_ACCOUNT_BACKEND* + - *AERC_FOLDER* + - *AERC_FOLDER_ROLE* + - *AERC_FROM_NAME* + - *AERC_FROM_ADDRESS* + - *AERC_SUBJECT* + - *AERC_MESSAGE_ID* + + Example: + + *mail-received* = _notify-send "[$AERC_ACCOUNT/$AERC_FOLDER] New mail from $AERC_FROM_NAME" "$AERC_SUBJECT"_ + +*mail-deleted* = _<command>_ + Executed when a message is deleted from a folder. Note that this hook is + triggered when moving a message from one folder to another. + + Variables: + + - *AERC_ACCOUNT* + - *AERC_ACCOUNT_BACKEND* + - *AERC_FOLDER* + - *AERC_FOLDER_ROLE* + + Example: + + *mail-deleted* = _mbsync "$AERC_ACCOUNT:$AERC_FOLDER"_ + +*mail-added* = _<command>_ + Executed when a message is added to a folder. Note that this hook is not + triggered when a new message is received (use *mail-received* for that) but + rather is only triggered when aerc itself adds a message to a folder, e.g. + when moving or copying a message. + + Variables: + + - *AERC_ACCOUNT* + - *AERC_ACCOUNT_BACKEND* + - *AERC_FOLDER* + - *AERC_FOLDER_ROLE* + + Example: + + *mail-added* = _mbsync "$AERC_ACCOUNT:$AERC_FOLDER"_ + +*mail-sent* = _<command>_ + Executed when a message is sent. This does not necessarily signify + successful posting, if a queueing system like msmtpq is used. + + Variables: + + - *AERC_ACCOUNT* + - *AERC_ACCOUNT_BACKEND* + - *AERC_FROM_NAME* + - *AERC_FROM_ADDRESS* + - *AERC_SUBJECT* + - *AERC_TO* + - *AERC_CC* + + Example: + + *mail-sent* = _if [ "$AERC_ACCOUNT" = "gmail" ]; then mbsync + gmail; fi_ + +*aerc-shutdown* = _<command>_ + Executed when aerc shuts down. Aerc will wait for the command to finish + before exiting. + + Variables: + + - *AERC_LIFETIME* + +*tag-modified* = _<command>_ + Executed when notmuch tags are modified in a notmuch account. The list + of added and removed tags are passed as variables. + + Variables: + + - *AERC_ACCOUNT* + - *AERC_ACCOUNT_BACKEND* + - *AERC_TAG_ADDED* + - *AERC_TAG_REMOVED* + +*flag-changed* = _<command>_ + Executed when flags are changed on a message. + + Variables: + + - *AERC_ACCOUNT* + - *AERC_ACCOUNT_BACKEND* + - *AERC_FOLDER* + - *AERC_FOLDER_ROLE* + - *AERC_FLAG* + + Example: + + *flag-changed* = _mbsync "$AERC_ACCOUNT:$AERC_FOLDER"_ + +# TEMPLATES + +Template files are used to populate the body of an email. The *:compose*, +*:reply* and *:forward* commands can be called with the *-T* flag with the name +of the template name. The available symbols and functions are described in +*aerc-templates*(7). + +aerc ships with some default templates installed in the share directory (usually +_/usr/share/aerc/templates_). + +These options are configured in the *[templates]* section of _aerc.conf_. + +*template-dirs* = _<path1:path2:path3...>_ + The directory where the templates are stored. The config takes + a colon-separated list of dirs. If this is unset or if a template cannot + be found, the following paths will be used as a fallback in that order: + + ``` + ${XDG_CONFIG_HOME:-~/.config}/aerc/templates + ${XDG_DATA_HOME:-~/.local/share}/aerc/templates + /usr/local/share/aerc/templates + /usr/share/aerc/templates + ``` + +*new-message* = _<template_name>_ + The default template to be used for new messages. + + Default: _new_message_ + +*quoted-reply* = _<template_name>_ + The default template to be used for quoted replies. + + Default: _quoted_reply_ + +*forwards* = _<template_name>_ + The default template to be used for forward as body. + + Default: _forward_as_body_ + +# SEE ALSO + +*aerc*(1) *aerc-accounts*(5) *aerc-binds*(5) *aerc-imap*(5) *aerc-jmap*(5) +*aerc-maildir*(5) *aerc-notmuch*(5) *aerc-templates*(7) *aerc-sendmail*(5) +*aerc-smtp*(5) *aerc-stylesets*(7) *carddav-query*(1) + +# AUTHORS + +Originally created by Drew DeVault and maintained by Robin Jarry who is assisted +by other open source contributors. For more information about aerc development, +see _https://sr.ht/~rjarry/aerc/_. diff --git a/doc/aerc-imap.5.scd b/doc/aerc-imap.5.scd new file mode 100644 index 0000000..9c4bf3c --- /dev/null +++ b/doc/aerc-imap.5.scd @@ -0,0 +1,164 @@ +AERC-IMAP(5) + +# NAME + +aerc-imap - IMAP configuration for *aerc*(1) + +# SYNOPSIS + +aerc implements the IMAP protocol as specified by RFC 3501, with the following +IMAP extensions: + +- IDLE (RFC 2177) +- LIST-STATUS (RFC 5819) +- X-GM-EXT-1 (Gmail) + +# CONFIGURATION + +Basic IMAP configuration may be done interactively with the *:new-account* +command. + +In _accounts.conf_ (see *aerc-accounts*(5)), the following IMAP-specific options +are available: + +*source* = _<scheme>_://_<username>_[_:<password>_]_@<hostname>_[_:<port>_]_?_[_<oauth2_params>_] + Remember that all fields must be URL encoded. The _@_ symbol, when URL + encoded, is _%40_. + + Possible values of _<scheme>_ are: + + _imap_ + IMAP with STARTTLS + + _imap+insecure_ + IMAP without STARTTLS + + _imaps_ + IMAP with TLS/SSL + + _imaps+insecure_ + IMAP with TLS/SSL, skipping certificate verification + + _imaps+oauthbearer_ + IMAP with TLS/SSL using OAUTHBEARER Authentication + + _<oauth2_params>_: + + If specified and a _token_endpoint_ is provided, the configured password + is used as a refresh token to obtain an access token. If _token_endpoint_ + is omitted, refresh token exchange is skipped, and the password acts + like an access token instead. + + - _token_endpoint_ (optional) + - _client_id_ (optional) + - _client_secret_ (optional) + - _scope_ (optional) + + Example: + imaps+oauthbearer://...?token_endpoint=https://...&client_id= + + _imaps+xoauth2_ + IMAP with TLS/SSL using XOAUTH2 Authentication. Parameters are + the same as OAUTHBEARER. + +*source-cred-cmd* = _<command>_ + Specifies the command to run to get the password for the IMAP + account. This command will be run using _sh -c command_. If a + password is specified in the *source* option, the password will + take precedence over this command. + + Example: + source-cred-cmd = pass hostname/username + +*connection-timeout* = _<duration_> + Maximum delay to establish a connection to the IMAP server. See + https://pkg.go.dev/time#ParseDuration. + + Default: _30s_ + +*keepalive-period* = _<duration>_ + The interval between the last data packet sent (simple ACKs are not + considered data) and the first keepalive probe. After the connection is + marked to need keepalive, this counter is not used any further. See + https://pkg.go.dev/time#ParseDuration. + + By default, the system tcp socket settings are used. + +*keepalive-probes* = _<int>_ + The number of unacknowledged probes to send before considering the + connection dead and notifying the application layer. + + By default, the system tcp socket settings are used. + If keepalive-period is specified, this option defaults to 3 probes. + + This option is only supported on linux. On other platforms, it will be + ignored. + +*keepalive-interval* = _<duration>_ + The interval between subsequential keepalive probes, regardless of what + the connection has exchanged in the meantime. Fractional seconds are + truncated. + + By default, the system tcp socket settings are used. + If *keepalive-period* is specified, this option defaults to _3s_. + + This option is only supported on linux. On other platforms, it will be + ignored. + +*check-mail-include* = _<folder1,folder2,folder3...>_ + Specifies the comma separated list of folders to include when checking for + new mail with *:check-mail*. Names prefixed with _~_ are interpreted as regular + expressions. This setting is ignored if your IMAP server supports the + LIST-STATUS command, in which case all folders will be checked. + + By default, all folders are included. + +*check-mail-exclude* = _<folder1,folder2,folder3...>_ + Specifies the comma separated list of folders to exclude when checking for + new mail with *:check-mail*. Names prefixed with _~_ are interpreted as regular + expressions. This setting is ignored if your IMAP server supports the + LIST-STATUS command, in which case all folders will be checked. + Note that this overrides anything from *check-mail-include*. + + By default, no folders are excluded. + +*cache-headers* = _true_|_false_ + If set to _true_, headers will be cached. The cached headers will be stored + in _$XDG_CACHE_HOME/aerc_, which defaults to _~/.cache/aerc_. + + Default: _false_ + +*cache-max-age* = _<duration>_ + Defines the maximum age of cached files. Note: the longest unit of time + *cache-max-age* can be specified in is hours. Set to _0_ to disable cleaning + the cache + + Default: _720h_ (30 days) + +*idle-timeout* = _<duration>_ + The length of time the client will wait for the server to send any final + update before the IDLE is closed. + + Default: _10s_ + +*idle-debounce* = _<duration>_ + Specifies the length of time from the last client command until the + idler starts. + + Default: _10ms_ + +*use-gmail-ext* = _true_|_false_ + If set to _true_, the X-GM-EXT-1 extension will be used if supported. + This only works for Gmail accounts. + + Default: _false_ + +# SEE ALSO + +*aerc*(1) *aerc-accounts*(5) + +# AUTHORS + +Originally created by Drew DeVault and maintained by Robin Jarry who is assisted +by other open source contributors. For more information about aerc development, +see _https://sr.ht/~rjarry/aerc/_. diff --git a/doc/aerc-jmap.5.scd b/doc/aerc-jmap.5.scd new file mode 100644 index 0000000..18b9f94 --- /dev/null +++ b/doc/aerc-jmap.5.scd @@ -0,0 +1,148 @@ +AERC-JMAP(5) + +# NAME + +aerc-jmap - JMAP configuration for *aerc*(1) + +# SYNOPSIS + +aerc implements the JMAP protocol as specified by RFCs 8620 and 8621. + +# CONFIGURATION + +Basic JMAP configuration may be done interactively with the *:new-account* +command. + +In _accounts.conf_ (see *aerc-accounts*(5)), the following JMAP-specific options +are available: + +*source* = _<scheme>_://[_<username>_][_:<password>@_]_<hostname>_[_:<port>_]/_<path>_ + Remember that all fields must be URL encoded. The _@_ symbol, when URL + encoded, is _%40_. + + _<hostname>_[_:<port>_]/_<path>_ is the HTTPS JMAP session resource as + specified in RFC 8620 section 2 without the leading _https://_ scheme. + + Possible values of _<scheme>_ are: + + _jmap_ + JMAP over HTTPS using Basic authentication. + + _jmap+oauthbearer_ + JMAP over HTTPS using OAUTHBEARER authentication + + The username is ignored and may be left empty. If specifying the + password, make sure to prefix it with _:_ to make it explicit + that the username is empty. Or set the username to any random + value. E.g.: + + ``` + source = jmap+oauthbearer://:s3cr3t@example.com/jmap/session + source = jmap+oauthbearer://me:s3cr3t@example.com/jmap/session + ``` + + Your source credentials must have the _urn:ietf:params:jmap:mail_ + capability. + +*source-cred-cmd* = _<command>_ + Specifies the command to run to get the password for the JMAP account. + This command will be run using _sh -c command_. If a password is + specified in the *source* option, the password will take precedence over + this command. + + Example: + source-cred-cmd = pass hostname/username + +*outgoing* = _jmap://_ + The JMAP connection can also be used to send emails. No need to repeat + the URL nor any credentials. Just the URL scheme will be enough. + + Your source credentials must have the _urn:ietf:params:jmap:submission_ + capability. + +*cache-state* = _true_|_false_ + Cache all email state (mailboxes, email headers, mailbox contents, email + flags, etc.) on disk in a levelDB database located in folder + _~/.cache/aerc/<account>/state_. + + The cached data should remain small, in the order of a few megabytes, + even for very large email stores. Aerc will make its best to purge + deleted/outdated information. It is safe to delete that folder when aerc + is not running and it will be recreated from scratch on next startup. + + Default: _false_ + +*cache-blobs* = _true_|_false_ + Cache all downloaded email bodies and attachments on disk as individual + files in _~/.cache/aerc/<account>/blobs/<xx>/<blob_id>_ (where _<xx>_ is + a subfolder named after the last two characters of _<blob_id>_). + + Aerc will not purge the cached blobs automatically. Even when their + related emails are destroyed permanently from the server. If required, + you may want to run some periodic cleanup based on file creation date in + a crontab, e.g.: + + @daily find ~/.cache/aerc/foo/blobs -type f -mtime +30 -delete + + Default: _false_ + +*use-labels* = _true_|_false_ + If set to _true_, mailboxes with the _archive_ role (usually _Archive_) + will be hidden from the directory list and replaced by an *all-mail* + virtual folder. The name of that folder can be configured via the + *all-mail* setting. + + *:archive* _flat_ may still be used to effectively "tag" messages with the + hidden _Archive_ mailbox so that they appear in the *all-mail* virtual + folder. When the *all-mail* virtual folder is selected, *:archive* _flat_ + should not be used and will have no effect. The messages will be grayed + out but will never be refreshed until aerc is restarted. + + Also, this enables support for the *:modify-labels* (alias *:tag*) + command. + + Default: _false_ + +*all-mail* = _<name>_ + Name of the virtual folder that replaces the role=_archive_ mailbox when + *use-labels* = _true_. + + Default: _All mail_ + +*server-ping* = _<duration>_ + Interval the server should ping the client at when monitoring for email + changes. The server may choose to ignore this value. By default, no ping + will be requested from the server. + + See https://pkg.go.dev/time#ParseDuration. + +# NOTES + +JMAP messages can be seen as "labels" or "tags". Every message must belong to +one or more mailboxes (folders in aerc). Each mailbox has a "role" as described +in _https://www.iana.org/assignments/imap-mailbox-name-attributes/_. + +When deleting messages that belong only to the selected mailbox, aerc will +attempt to "move" these messages to a mailbox with the _trash_ role. If it +cannot find such mailbox or if the selected mailbox is the _trash_ mailbox, it +will effectively destroy the messages from the server. + +*:delete* removes messages from the selected mailbox and effectively does the +same thing than *:tag -<selected_folder>*. + +*:cp <foo>* is an alias for *:tag <foo>* or *:tag +<foo>*. + +*:mv <foo>* is a compound of *:delete* and *:mv* and can be seen as an alias of +*:tag -<selected_folder> +<foo>*. + +*:archive* _flat_ is an alias for *:tag -<selected_folder> +<archive>*. + +# SEE ALSO + +*aerc*(1) *aerc-accounts*(5) + +# AUTHORS + +Originally created by Drew DeVault and maintained by Robin Jarry who is assisted +by other open source contributors. For more information about aerc development, +see _https://sr.ht/~rjarry/aerc/_. diff --git a/doc/aerc-maildir.5.scd b/doc/aerc-maildir.5.scd new file mode 100644 index 0000000..83dfa04 --- /dev/null +++ b/doc/aerc-maildir.5.scd @@ -0,0 +1,58 @@ +AERC-MAILDIR(5) + +# NAME + +aerc-maildir - maildir configuration for *aerc*(1) + +# SYNOPSIS + +aerc implements the maildir format. + +# CONFIGURATION + +Basic Maildir configuration may be done interactively with the *:new-account* +command. + +The following maildir-specific options are available: + +*check-mail-cmd* = _<command>_ + Command to run in conjunction with *check-mail* option. + + Example: + check-mail-cmd = mbsync -a + +*check-mail-timeout* = _<duration>_ + Timeout for the *check-mail-cmd*. The command will be stopped if it does + not complete in this interval and an error will be displayed. Increase from + the default if repeated errors occur + + Default: 10s + +*source* = _maildir_|_maildirpp_://_<path>_ + The *source* indicates the path to the directory containing your maildirs + rather than one maildir specifically. + + The path portion of the URL following _maildir://_ must be either an absolute + path prefixed by _/_ or a path relative to your home directory prefixed with + *~*. For example: + + source = maildir:///home/me/mail + + source = maildir://~/mail + + If your maildir is using the Maildir++ directory layout, you can use the + _maildirpp://_ scheme instead: + + source = maildirpp:///home/me/mail + + source = maildirpp://~/mail + +# SEE ALSO + +*aerc*(1) *aerc-accounts*(5) *aerc-smtp*(5) *aerc-notmuch*(5) + +# AUTHORS + +Originally created by Drew DeVault and maintained by Robin Jarry who is assisted +by other open source contributors. For more information about aerc development, +see _https://sr.ht/~rjarry/aerc/_. diff --git a/doc/aerc-notmuch.5.scd b/doc/aerc-notmuch.5.scd new file mode 100644 index 0000000..4f11121 --- /dev/null +++ b/doc/aerc-notmuch.5.scd @@ -0,0 +1,122 @@ +AERC-NOTMUCH(5) + +# NAME + +aerc-notmuch - notmuch configuration for *aerc*(1) + +# SYNOPSIS + +aerc supports using the notmuch email system as a backend, for fast indexing +and searching. + +For this to be enabled, aerc needs to be built with notmuch support. +Refer to the installation instructions for details. + +# CONFIGURATION + +Basic Notmuch configuration may be done interactively with the *:new-account* +command. + +In _accounts.conf_ (see *aerc-accounts*(5)), the following notmuch-specific +options are available: + +*check-mail-cmd* = _<command>_ + Command to run in conjunction with *check-mail* option. + + Example: + check-mail-cmd = mbsync -a + +*check-mail-timeout* = _<duration>_ + Timeout for the *check-mail-cmd*. The command will be stopped if it does + not complete in this interval and an error will be displayed. Increase from + the default if repeated errors occur + + Default: _10s_ + +*source* = notmuch://_<path>_ + The *source* indicates the path to the directory containing your notmuch + database (usually a _.notmuch/_ folder). + + The path portion of the URL following _notmuch://_ must be either an absolute + path prefixed by _/_ or a path relative to your home directory prefixed with + _~_. For example: + + source = notmuch:///home/me/mail + + source = notmuch://~/mail + +*query-map* = _<file>_ + Path to a file containing a mapping from display name to notmuch query + in the form of *<NAME>*=_<QUERY>_. + + Multiple entries can be specified, one per line. Lines starting with _#_ + are ignored and serve as comments. + + e.g. inbox=tag:inbox and not tag:archived + +*exclude-tags* = _<tag1,tag2,tag3...>_ + Comma separated list of tags which will be excluded from query results, + unless explicitly mentioned in the query. + + This can for example be useful if you use an _archive_ or _spam_ tag. + +*maildir-store* = _<path>_ + Path to the maildir store containing the message files backing the + notmuch database. This is often the same as the notmuch database path. + If specified, this option will be used by aerc to list available folders + and enable commands such as *:delete* and *:archive*. + + N.B.: aerc will still always show messages and not files (under notmuch, + a single message can be represented by several files), which makes the + semantics of certain commands as *move* ambiguous. Use *multi-file-strategy* + to tell aerc how to resolve these ambiguities. + +*maildir-account-path* = _<path>_ + Path to the maildir account relative to the *maildir-store*. + + This could be used to achieve traditional maildir one tab per account + behavior. The note on *maildir-store* also applies to this option. + +*multi-file-strategy* = _<strategy>_ + Strategy for file operations (e.g., move, copy, delete) on messages that are + backed by multiple files. Possible values: + + - *refuse* (default): Refuse to act. + - *act-all*: Act on all files. + - *act-one*: Act on one of the files, arbitrarily chosen, and ignore the + rest. + - *act-one-delete-rest*: Like *act-one*, but delete the remaining files. + - *act-dir*: Act on all files within the current folder and ignore the rest. + Note that this strategy only works within the maildir directories; in other + directories, it behaves like *refuse*. + - *act-dir-delete-rest*: Like *act-dir*, but delete the remaining files. + + Note that the strategy has no effect on cross-account operations. Copying a + message across accounts will always copy a single file, arbitrarily chosen. + Moving a message across accounts will always copy a single file, arbitrarily + chosen, and refuse to delete multiple files from the source account. + +# USAGE + +Notmuch shows slightly different behavior than for example imap. Some commands +are slightly different in semantics and mentioned below: + +*cf* _<notmuch query>_ + The change folder command allows for arbitrary notmuch queries. Performing a + *:cf* command will perform a new top-level notmuch query. + +*filter* _<notmuch query>_ + The filter command for notmuch backends takes in arbitrary notmuch queries. + It applies the query on the set of messages shown in the message list. This + can be used to perform successive filters/queries. It is equivalent to + performing a set of queries concatenated with "and". + +# SEE ALSO + +*aerc*(1) *aerc-accounts*(5) *aerc-smtp*(5) *aerc-maildir*(5) + +# AUTHORS + +Originally created by Drew DeVault and maintained by Robin Jarry who is assisted +by other open source contributors. For more information about aerc development, +see _https://sr.ht/~rjarry/aerc/_. diff --git a/doc/aerc-patch.7.scd b/doc/aerc-patch.7.scd new file mode 100644 index 0000000..b24fa68 --- /dev/null +++ b/doc/aerc-patch.7.scd @@ -0,0 +1,195 @@ +AERC-PATCH(7) + +# NAME + +aerc-patch - local patch management for *aerc*(1) + +# SYNOPSIS + +*aerc* provides support for managing local patch sets. In an email-based +software development workflow, there are usually many different locally applied +patch series for testing and reviewing. Managing the local repository can thus +be challenging. With the local patch management system, *aerc* facilitates this +bookkeeping process. + +When applying a patch set, *aerc* creates a tag for those commits. With this +tag, the patch set can be tracked and later dropped if needed. Patches are +stored in a project data structure which also keeps track of the directory where +the repository is. Multiple code bases can be tracked by defining a separate +project for each. + +# COMMANDS + +The following *:patch* sub-commands are supported: + +*:patch init* [*-f*] [_<project>_] + Creates a new project _<project>_. If _<project>_ is not defined, *aerc* + will use the last element of the current directory path. It also + performs a search for a supported repository in the current directory. + + *-f*: Overwrite an existing project. + +*:patch list* [*-a*]++ +*:patch ls* [*-a*] + Lists the current project with the tracked patch sets. + + *-a*: Lists all projects. + +*:patch apply* [*-c* _<cmd>_] [*-w* _<commit-ish>_] _<tag>_ + Applies the selected message(s) to the repository of the current + project. It uses the *:pipe* command for this and keeps track of the + applied patch. + + Completions for the _<tag>_ are available based on the subject lines of + the selected or marked messages. + + *-c* _<cmd>_: Apply patches with the provided _<cmd>_. Any occurrence of + '%r' in the command string will be replaced with the root directory of + the current project. Note that this approach is not recommended in + general and should only be used for very specific purposes, i.e. when + a maintainer is applying a patch set via a separate script to deal with + git trailers. + + *aerc* will propose completions for the _<tag>_ based on the subject + lines of the selected or marked messages. + + Example: + ``` + :patch apply -c "git -C %r am -3" fix_v2 + ``` + + *-w* _<commit-ish>_: Create a linked worktree for the current project at + _<commit-ish>_ and apply the patches to the linked worktree. A new + project is created to store the worktree information. When this project + is deleted, the worktree will be deleted as well. + + Example: + ``` + :patch apply -w origin/master fix_v2 + ``` + +*:patch drop* _<tag>_ + Drops the patch _<tag>_ from the repository. + +*:patch rebase* [_<commit-ish>_] + Rebases the patch data on commit _<commit-ish>_. + + If the _<commit-ish>_ is omitted, *aerc* will use the base commit of + the current project for the rebase. + +*:patch find* [*-f*] _<commit-hash>_ + Searches the messages in the current folder of the current account for + the message associated with this _commit hash_ based on the subject line. + + If a Message-ID is linked to a commit (i.e. when *:patch apply* was + used) then *find* will first perform a search for the Message-ID. + + *-f*: Filter the message list instead of just showing the search + results. Only effective when search for Message-ID was not successful. + +*:patch cd* + Changes the working directory to the root directory of the current + project. + +*:patch term* [_<cmd>_] + Opens a shell (or runs _<cmd>_) in the working directory of the + current project. + +*:patch switch* _<project>_ + Switches the context to _<project>_. + +*:patch unlink* [_<project>_] + Deletes all patch tracking data for _<project>_ and unlinks it from + a repository. If no project is provided, the current project is deleted. + +*:patch* + Root command for path management. Use it to run the sub-commands. + +# GETTING STARTED + +Make sure you have an initialized project (see *:patch init*). + +Now, there are two ways to get patches under the local patch management system: + +- Apply patches with the *:patch apply* command. This will automatically create + a new tag for the applied commits. + +- Use *:patch rebase*. If there are some existing local patches in the commit + history that should be managed by *aerc*, you can run *:patch rebase + <commit-ish>* and set the _<commit-ish>_ to the commit before the first patch + that you want to include. For a *git* repository which has an upstream called + *origin*, you would run *:patch rebase origin/master*. + +# EXAMPLE + +The following example demonstrates how to manage the local patch sets. + +First, a project needs to be initialized. This is done by changing into the +working directory where the project's repository is located. For this example, +let's assume we have a project called _bar_ in the directory +_/home/user/foo/bar_. + +``` +:cd /home/user/foo/bar +``` + +and then creating a new project with + +``` +:patch init +``` + +If no name is provided to *:patch init*, *aerc* will use the last element of the +working directory path (here: _bar_). + +Now the patch tracking is ready for action. Go to the message list, mark a patch +series and apply it: + +``` +:patch apply fix_v2 +``` + +This will apply the selected patch set and assigns the _fix_v2_ tag to those +commits. The tag helps to keep the commits grouped together, and will be helpful +when we want to drop this exact patch set at a later point. + +With *:patch list* you can verify that the patch set was correctly applied. + +If there is a change in the underlying repository (e.g. by rebasing to +upstream), the hashes of the applied local commits can change. *:patch list* can +detect such a change and will then propose to rebase the internal data. To +do this, run + +``` +:patch rebase +``` + +This will open an editor where you can adjust the correct tags again. You could +also change the rebase point by providing an optional argument (e.g. a commit +hash, or even _HEAD~3_ or _origin/master_, etc.). + +To drop a patch set, use the tag that was assigned during applying: + +``` +:patch drop fix_v2 +``` + +And to delete the project data in *aerc*: + +``` +:patch unlink bar +``` + +# SUPPORTED REVISION CONTROL SYSTEMS + +The supported revision control systems are currently: *git*. + +# SEE ALSO + +*aerc*(1) + +# AUTHORS + +Originally created by Drew DeVault and maintained by Robin Jarry who is assisted +by other open source contributors. For more information about aerc development, +see _https://sr.ht/~rjarry/aerc/_. diff --git a/doc/aerc-search.1.scd b/doc/aerc-search.1.scd new file mode 100644 index 0000000..dd3706e --- /dev/null +++ b/doc/aerc-search.1.scd @@ -0,0 +1,129 @@ +AERC-SEARCH(1) + +# NAME + +aerc-search - search and filter patterns and options for *aerc*(1) + +# SYNTAX + +This syntax is common to all backends. + +*:filter* [*-rubae*] [*-x* _<flag>_] [*-X* _<flag>_] [*-H* _<header>:[<value>]_] [*-f* _<from>_] [*-t* _<to>_] [*-c* _<cc>_] [*-d* _<start[..end]>_] [_<terms>_...]++ +*:search* [*-rubae*] [*-x* _<flag>_] [*-X* _<flag>_] [*-H* _<header>:[<value>]_] [*-f* _<from>_] [*-t* _<to>_] [*-c* _<cc>_] [*-d* _<start[..end]>_] [_<terms>_...] + Searches the current folder for messages matching the given set of + conditions. + + *:filter* restricts the displayed messages to only the search results. + + Each space separated term of _<terms>_, if provided, is searched + case-insensitively among subject lines unless *-b* or *-a* are + provided. + + *-r*: Search for read messages + + *-u*: Search for unread messages + + *-x* _<flag>_, *-X* _<flag>_: Restrict search to messages with or without _<flag>_ + Use *-x* to search for messages with the flag set. + Use *-X* to search for messages without the flag set. + + Possible values are: + _Seen_ + Read messages + _Answered_ + Replied messages + _Forwarded_ + Forwarded messages + _Flagged_ + Flagged messages + _Draft_ + Draft messages + + *-H* _<header>:[<value>]_: + Search in the headers of the messages for a specific _<header>_ that matches _<value>_, + _<value>_ can be omitted to only search for a _<header>_. + If either the _<header>_ or the _<value>_ contain a space then the whole argument needs + to be escaped with quotes, note: spaces around _<value>_ are trimmed. + + *-b*: Search in the body of the messages + + *-a*: Search in the entire text of the messages + + *-e*: Instruct the backend to use a custom search extension + (such as X-GM-EXT-1 if available). Search terms are expected + in _<terms>_; other flags will be ignored. + + *-f* _<from>_: Search for messages from _<from>_ + + *-t* _<to>_: Search for messages to _<to>_ + + *-c* _<cc>_: Search for messages cc'ed to _<cc>_ + + *-d* _<since[..until]>_: + Search for messages within a particular date range between + _since_ and _until_, excluding the latter (in mathematical + notation: search for messages in the [_since_, _until_) + interval). _until_ can be omitted to only search for _<since>_ + to present. + + Spaces and underscores are allowed in relative dates to improve + readability. + + _YYYY-MM-DD_ + + *today*, *yesterday* + + *(this|last) (year|month|week)* + + *Weekdays*, *Monthnames* + Can also be abbreviated, so Monday..Tuesday can be written + as Mon..Tue and February..March as Feb..Mar. + + _<N>_ *(y[ear]|m[onth]|w[eek]|d[ay])* + _<N>_ is a positive integer that represents the number + of time units in the past. Multiple relative terms + can be accumulated. The units can also be abbreviated + by a single letter such that yesterday would + correspond to _1d_ (equivalent to _1 day_ or _1_day_) + and _8 days ago_ would be either _1w1d_ or _8d_. + +# CUSTOM IMAP EXTENSIONS + +The Gmail IMAP extension (X-GM-EXT-1) can be used for searching and filtering. +To use this custom extension, make sure it is activated (see *aerc-imap*(5)) +and use the extension *-e* flag in your *:filter* or *:search* commands. + + Example: + + :filter -e filename:pdf from:bob + :filter -e has:attachment newer_than:2d + + :search -e is:read is:starred + :search -e list:~rjarry/aerc-devel@lists.sr.ht + + +# NOTMUCH + +For notmuch, it is possible to avoid using the above flags and only rely on +notmuch search syntax. + +*:filter* _query_...++ +*:search* _query_... + You can use the full notmuch query language as described in + *notmuch-search-terms*(7). + + The query will only apply on top of the active folder query. + + Example, jump to next unread: + + :search tag:unread + +# SEE ALSO + +*aerc*(1) *aerc-config*(5) + +# AUTHORS + +Originally created by Drew DeVault and maintained by Robin Jarry who is assisted +by other open source contributors. For more information about aerc development, +see _https://sr.ht/~rjarry/aerc/_. diff --git a/doc/aerc-sendmail.5.scd b/doc/aerc-sendmail.5.scd new file mode 100644 index 0000000..8f8b3e6 --- /dev/null +++ b/doc/aerc-sendmail.5.scd @@ -0,0 +1,32 @@ +AERC-SENDMAIL(5) + +# NAME + +aerc-sendmail - sendmail configuration for *aerc*(1) + +# SYNOPSIS + +aerc can defer to sendmail for the delivery of outgoing messages. + +# CONFIGURATION + +Basic sendmail configuration may be done interactively with the *:new-account* +command. + +In _accounts.conf_ (see *aerc-accounts*(5)), the following sendmail-specific +options are available: + +*outgoing* = _</path/to/sendmail>_ + This should be set to the path to the sendmail binary you wish to use, + which is generally _/usr/sbin/sendmail_. aerc will execute it with a list of + recipients on the command line and pipe the message to deliver to stdin. + +# SEE ALSO + +*aerc*(1) *aerc-accounts*(5) + +# AUTHORS + +Originally created by Drew DeVault and maintained by Robin Jarry who is assisted +by other open source contributors. For more information about aerc development, +see _https://sr.ht/~rjarry/aerc/_. diff --git a/doc/aerc-smtp.5.scd b/doc/aerc-smtp.5.scd new file mode 100644 index 0000000..4dfee70 --- /dev/null +++ b/doc/aerc-smtp.5.scd @@ -0,0 +1,79 @@ +AERC-SMTP(5) + +# NAME + +aerc-smtp - SMTP configuration for *aerc*(1) + +# SYNOPSIS + +aerc implements the SMTP protocol as specified by RFC 5321. + +# CONFIGURATION + +Basic SMTP configuration may be done interactively with the *:new-account* +command. + +In _accounts.conf_ (see *aerc-accounts*(5)), the following SMTP-specific options +are available: + +*outgoing* = _<scheme>_[_+<auth>_]://_<username>_[_:<password>_]_@<hostname>_[_:<port>_]?[_<oauth2_params>_] + Remember that all fields must be URL encoded. The _@_ symbol, when URL + encoded, is _%40_. + + The value of _<scheme>_ can be: + + _smtp_ + SMTP with STARTTLS + + _smtp+insecure_ + SMTP without STARTTLS + + _smtps_ + SMTP with TLS/SSL + + Additionally, you can specify an authentication mechanism like so: + + _none_ + No authentication is required to use this SMTP server. You may omit the + username and password in this case. + + _plain_ + Authenticate with a username and password using AUTH PLAIN. This is the + default behavior. + + _login_ + Authenticate with a username and password using AUTH LOGIN. This is an obsolete + protocol, but is required for some common webmail providers. + + _oauthbearer_ + SMTP with TLS/SSL using OAUTHBEARER Authentication. See + documentation in *aerc-imap*(5) for usage. + + _xoauth2_ + SMTP with TLS/SSL using XOAUTH2 Authentication. See + documentation in *aerc-imap*(5) for usage. + +*outgoing-cred-cmd* = _<command>_ + Specifies the command to run to get the password for the SMTP + account. This command will be run using _sh -c [command]_. If a + password is specified in the *outgoing* option, the password will + take precedence over this command. + + Example: + outgoing-cred-cmd = pass hostname/username + +*smtp-domain* = _<domain>_ + Local domain name to use in the HELO/EHLO SMTP command. Set this to a fully + qualified domain name if the server requires it as an antispam measure. + + Default: _localhost_ + +# SEE ALSO + +*aerc*(1) *aerc-accounts*(5) + +# AUTHORS + +Originally created by Drew DeVault and maintained by Robin Jarry who is assisted +by other open source contributors. For more information about aerc development, +see _https://sr.ht/~rjarry/aerc/_. diff --git a/doc/aerc-stylesets.7.scd b/doc/aerc-stylesets.7.scd new file mode 100644 index 0000000..7571738 --- /dev/null +++ b/doc/aerc-stylesets.7.scd @@ -0,0 +1,466 @@ +AERC-STYLESETS(7) + +# NAME + +aerc-stylesets - styleset file specification for *aerc*(1) + +# SYNOPSIS + +aerc uses a simple configuration syntax to configure the styleset for +its ui. + +# STYLESET CONFIGURATION + +The styleset is described as *<object>*.*<attribute>* = _<value>_ pairs. + +For example, in the line below, the foreground color of the +style object *msglist_unread* is set to _cornflowerblue_ + + *msglist_unread*.*fg* = _cornflowerblue_ + +The configuration also allows wildcard matching of the keys +to configure multiple style objects at a time. + +# ATTRIBUTES + +The following options are available to be modified for each of the +style objects. + +*<object>*.*fg* = _<color>_ + The foreground color of the style object is set. + +*<object>*.*bg* = _<color>_ + The background color of the style object is set. + +*<object>*.*bold* = _true_|_false_|_toggle_ + The bold attribute of the style object is set/unset. + +*<object>*.*blink* = _true_|_false_|_toggle_ + The blink attribute of the style object is set/unset. + The terminal needs to support blinking text. + +*<object>*.*underline* = _true_|_false_|_toggle_ + The underline attribute of the style object is set/unset. + The terminal needs to support underline text. + +*<object>*.*italic* = _true_|_false_|_toggle_ + The italic attribute of the style object is set/unset. + The terminal needs to support italic text. + +*<object>*.*dim* = _true_|_false_|_toggle_ + The dim attribute of the style object is set/unset. + The terminal needs to support half-bright text. + +*<object>*.*reverse* = _true_|_false_|_toggle_ + Reverses the color of the style object. Exchanges the foreground + and background colors. + + If the value is _false_, it doesn't change anything. + +*<object>*.*normal* = true + All the attributes of the style object are unset. + + The value doesn't matter. + +*<object>*.*default* = true + Set the style object to the default style of the context. Usually + based on the terminal. + + The value doesn't matter. + +# STYLE OBJECTS + +The style objects represent the various ui elements or ui instances for +styling. + +[[ *Style Object* +:[ *Description* +| *default* +: The default style object used for normal ui elements while not using specialized configuration. +| *error* +: The style used to show errors. +| *warning* +: The style used when showing warnings. +| *success* +: The style used for success messages. +| *title* +: The style object used to style titles in ui elements. +| *header* +: The style object used to style headers in ui elements. +| *statusline_default* +: The default style applied to the statusline. +| *statusline_error* +: The style used for error messages in statusline. +| *statusline_success* +: The style used for success messages in statusline. +| *msglist_default* +: The default style for messages in a message list. +| *msglist_unread* +: Unread messages in a message list. +| *msglist_read* +: Read messages in a message list. +| *msglist_flagged* +: The messages with the flagged flag. +| *msglist_deleted* +: The messages marked as deleted. +| *msglist_marked* +: The messages with the marked flag. +| *msglist_result* +: The messages which match the current search. +| *msglist_answered* +: The messages marked as answered. +| *msglist_forwarded* +: The messages marked as forwarded. +| *msglist_gutter* +: The message list gutter. +| *msglist_pill* +: The message list pill. +| *msglist_thread_folded* +: Visible messages that have folded thread children. +| *msglist_thread_context* +: The messages not matching the mailbox / query, displayed for context. +| *msglist_thread_orphan* +: Threaded messages that have a missing parent message. +| *dirlist_default* +: The default style for directories in the directory list. +| *dirlist_unread* +: The style used for directories with unread messages +| *dirlist_recent* +: The style used for directories with recent messages +| *part_switcher* +: Background for the part switcher in the message viewer. +| *part_filename* +: Attachment file name in the part switcher. +| *part_mimetype* +: Attachment/part MIME type in the part switcher. +| *completion_default* +: The default style for the completion engine. +| *completion_description* +: Completion item descriptions. +| *completion_gutter* +: The completion gutter. +| *completion_pill* +: The completion pill. +| *tab* +: The style for the tab bar. +| *stack* +: The style for ui stack element. +| *spinner* +: The style for the loading spinner. +| *border* +: The style used to draw borders (only the *bg* color is used unless you customize *border-char-vertical* and/or *border-char-horizontal* in _aerc.conf_). +| *selector_default* +: The default style for the selector ui element. +| *selector_focused* +: The focused item in a selector ui element. +| *selector_chooser* +: The item chooser in a selector ui element. + +These next style objects only affect the built-in *colorize* filter and must be +declared under a *[viewer]* section of the styleset file. + +[[ *Style Object* +:[ *Description* +| *url* +: URLs. +| *header* +: RFC-822-like header names. +| *signature* +: Email signatures. +| *diff_meta* +: Patch diff meta lines. +| *diff_chunk* +: Patch diff chunks. +| *diff_chunk_func* +: Patch diff chunk function names. +| *diff_add* +: Patch diff added lines. +| *diff_del* +: Patch diff deleted lines. +| *quote_1* +: First level quoted text. +| *quote_2* +: Second level quoted text. +| *quote_3* +: Third level quoted text. +| *quote_4* +: Fourth level quoted text. +| *quote_x* +: Above fourth level quoted text. + +User defined styles can be used to style arbitrary strings in go-templates (see +_.Style_ in *aerc-templates*(7)). User styles must be defined in the _[user]_ +ini section. Styles can be referenced by their name (e.g. _red.fg_ is named +"red"). + +Example: + +``` +[user] +red.fg=red +``` + +User styles are layered with other styles applied to the context in which they +are rendered. The user style colors (fg and/or bg) will only be effective if the +context style does not define any. Other boolean attributes will be merged with +the underlying style boolean attributes. + +For example, if the context style is: + + fg=red bold + +And the inline style is: + + fg=yellow italic underline + +The effective style will be: + + fg=red bold italic underline + +# FNMATCH STYLE WILDCARD MATCHING + +The styleset configuration can be made simpler by using the fnmatch +style wildcard matching for the style object. + +The special characters used in the fnmatch wildcards are: + +[[ *Pattern* +:[ *Meaning* +| *\** +: Matches everything +| *\?* +: Matches any single character + +For example, the following wildcards can be made using this syntax. + +[[ *Example* +:[ *Description* +| *\**.*fg* = _blue_ +: Set the foreground color of all style objects to blue. +| *\*list*.*bg* = _hotpink_ +: Set the background color of all style objects that end in list to hotpink. + +Note that the statements in a given styleset are parsed in the order in which +they are written. That means that with the following styleset: + +``` +msglist_marked.fg = pink +msglist_*.fg = white +``` + +The *msglist_marked.fg* attribute will be set to _white_. + +# SELECTED MODIFIER + +The *selected* modifier can be applied to any style object. The style provided for +the *selected* modifier is applied on top of the style object it corresponds to. + +If you would like to make sure message that are flagged as read in the msglist +appear in yellow foreground and black background. You can specify that with +this: + + *msglist_default*.*selected*.*fg* = _yellow_ + + *msglist_default*.*selected*.*bg* = _black_ + +If we specify the global style selected modifier using fnmatch as below: + + *\**.*selected*.*reverse* = _toggle_ + +This toggles the reverse switch for selected version of all the style objects. + +*selected* objects inherit from all attributes of their non-selected +counterparts. *selected* statements are parsed after non-selected ones and +effectively override the attributes of the non-selected style object. + +# LAYERED STYLES + +Some styles, (currently the *msglist_\** and *dirlist_\** ones) are applied in +layers. If a style differs from the base (in this case *\*list_default*) then +that style applies, unless overridden by a higher layer. If *fg* and *bg* colors +are not defined explicitly (or defined to the default color) they will be +considered as "transparent" and the colors from the lower layer will be used +instead. + +The order that *msglist_\** styles are applied in is, from first to last: + +. *msglist_default* +. *msglist_unread* +. *msglist_read* +. *msglist_answered* +. *msglist_forwarded* +. *msglist_flagged* +. *msglist_deleted* +. *msglist_result* +. *msglist_thread_folded* +. *msglist_thread_context* +. *msglist_thread_orphan* +. *msglist_marked* + +So, the marked style will override all other msglist styles. + +The order for *dirlist_\** styles is: + +. *dirlist_default* +. *dirlist_unread* +. *dirlist_recent* + +# DYNAMIC MESSAGE LIST STYLES + +All *msglist_\** styles can be defined for specific email header values. The +syntax is as follows: + + *msglist_<name>*._<header>_,_<header_value>_.*<attribute>* = _<attr_value>_ + +If _<header_value>_ starts with a tilde character _~_, it will be interpreted as +a regular expression. If you are writing regular expressions that try to match +with _._ or _\._ you need to wrap like this _~/<expression>/_. + +_<header>,<header_value>_ can be specified multiple times to narrow down matches +to more than one email header value. In that case, all given headers must match +for the dynamic style to apply. + +Examples: + +``` +msglist\*.X-Sourcehut-Patchset-Update,APPROVED.fg = green +msglist\*.X-Sourcehut-Patchset-Update,NEEDS\_REVISION.fg = yellow +msglist\*.X-Sourcehut-Patchset-Update,REJECTED.fg = red +"msglist_*.Subject,~^(\\[[\w-]+\]\\s*)?\\[(RFC )?PATCH.fg" = #ffffaf +"msglist_*.Subject,~^(\\[[\w-]+\]\\s*)?\\[(RFC )?PATCH.selected.fg" = #ffffaf +"msglist_*.From,~^Bob.Subject,~^(\\[[\w-]+\]\\s*)?\\[(RFC )?PATCH.selected.fg" = #ffffaf +"msglist_*.List-ID,~/lists\.sr\.ht/selected.fg" = blue +``` + +When a dynamic style is matched to an email header, it will be used in priority +compared to its non-dynamic counterpart. Provided the following styleset: + +``` +msglist_marked.fg = blue +msglist_*.Subject,~foobar.fg = red +``` + +An email with _foobar_ in its subject will be colored in _red_ all the time, +since *msglist_\** also applies to *msglist\_marked*. + +When multiple _<header>,<header_value>_ pairs are given, the last style which +matches all given patterns will be applied. Provided the following styleset: + +``` +msglist_*.From,~^Bob.Subject,~foobar.fg = red +msglist_*.From,~^Bob.fg = blue +``` + +An email from _Bob_ with _foobar_ in its subject will be colored in _blue_, +since the second style is a full match too. + +# COLORS + +The color values are set using any of the following methods: + +_default_ + The color is set as per the system or terminal default. + +_<Color name>_ + Any w3c approved color name is used to set colors for the style. + +_<Hex code>_ + Hexcode for a color can be used. The format must be _#XXXXXX_. + +_<Dec number>_ + Color based on the terminal palette index. Valid numbers are + between _0_ and _255_. + +# DEFAULTS + +Before parsing a styleset, it is first initialized with the following defaults: + +``` +*.selected.bg = 12 +*.selected.fg = 15 +*.selected.bold = true +statusline_*.dim = true +*warning.dim = false +*warning.bold = true +*warning.fg = 11 +*success.dim = false +*success.bold = true +*success.fg = 10 +*error.dim = false +*error.bold = true +*error.fg = 9 +border.bg = 12 +border.fg = 15 +title.bg = 12 +title.fg = 15 +title.bold = true +header.fg = 4 +header.bold = true +msglist_unread.bold = true +msglist_deleted.dim = true +msglist_marked.bg = 6 +msglist_marked.fg = 15 +msglist_pill.bg = 12 +msglist_pill.fg = 15 +part_mimetype.fg = 12 +selector_chooser.bold = true +selector_focused.bold = true +selector_focused.bg = 12 +selector_focused.fg = 15 +completion_*.bg = 8 +completion_pill.bg = 12 +completion_default.fg = 15 +completion_description.fg = 15 +completion_description.dim = true + +[viewer] +url.underline = true +url.fg = 3 +header.bold = true +header.fg = 4 +signature.dim = true +signature.fg = 4 +diff_meta.bold = true +diff_chunk.fg = 6 +diff_chunk_func.fg = 6 +diff_chunk_func.dim = true +diff_add.fg = 2 +diff_del.fg = 1 +quote_1.fg = 6 +quote_2.fg = 4 +quote_3.fg = 6 +quote_3.dim = true +quote_4.fg = 4 +quote_4.dim = true +quote_x.fg = 5 +quote_x.dim = true +``` + +You can choose either to reset everything (except in the *[viewer]* section) by +starting your styleset with these two lines: + +``` +*.default=true +*.normal=true +``` + +Or selectively override style object attributes. + +If you want to also reset the *[viewer]* section, you need to insert the same +two lines: + +``` +[viewer] +*.default=true +*.normal=true +``` + +# SEE ALSO + +*aerc*(1) *aerc-config*(5) + +# AUTHORS + +Originally created by Drew DeVault and maintained by Robin Jarry who is assisted +by other open source contributors. For more information about aerc development, +see _https://sr.ht/~rjarry/aerc/_. diff --git a/doc/aerc-templates.7.scd b/doc/aerc-templates.7.scd new file mode 100644 index 0000000..3192445 --- /dev/null +++ b/doc/aerc-templates.7.scd @@ -0,0 +1,598 @@ +AERC-TEMPLATES(7) + +# NAME + +aerc-templates - template file specification for *aerc*(1) + +# SYNOPSIS + +aerc uses the go text/template package for the template parsing. +Refer to the go text/template documentation for the general syntax. +The template syntax described below can be used for message template files and +for dynamic formatting of some UI app. + +Template files are composed of headers, followed by a newline, followed by the +body text. + +Example: + +``` +X-Clacks-Overhead: GNU Terry Pratchett + +Hello, + +Greetings, +Chuck +``` + +If you have a template that doesn't add any header, it *must* be preceded by a +newline, to avoid parsing parts of the body as header text. + +All headers defined in the template will have precedence over any headers that +are initialized by aerc (e.g. Subject, To, From, Cc) when composing a new +message, forwarding or replying. + +# MESSAGE DATA + +The following data can be used in templates. Though they are not all +available always. + +*Addresses* + An array of mail.Address. That can be used to add sender or recipient + names to the template. + + - _{{.From}}_: List of senders. + - _{{.Peer}}_: List of senders or To recipients if the message is from + you. + - _{{.To}}_: List of To recipients. Not always Available. + - _{{.ReplyTo}}_: List of ReplyTo recipients. Not always Available. + - _{{.Cc}}_: List of Cc recipients. Not always Available. + - _{{.Bcc}}_: List of Cc recipients. Not always Available. + - _{{.OriginalFrom}}_: List of senders of the original message. + Available for quoted reply and forward. + + Example: + + Get the name of the first sender. + ``` + {{(index .From 0).Name}} + {{index (.From | names) 0}} + ``` + + Get the email address of the first sender. + ``` + {{(index .From 0).Address}} + ``` + +*Date and Time* + The date and time information is always available and can be easily + formatted. + + - _{{.Date}}_: Date and time information when the compose window is opened. + - _{{.OriginalDate}}_: Date and time when the original message was received. + Available for quoted reply and forward. + + To format the date fields, _dateFormat_ and _.Local_ are provided. + Refer to the *TEMPLATE FUNCTIONS* section for details. + +*Subject* + The subject of the email (_ThreadPrefix_ will be empty unless threading + is enabled). + + ``` + {{.ThreadPrefix}}{{if .ThreadFolded}}{{printf "{%d}" .ThreadCount}}{{end}}{{.Subject}} + ``` + + The subject of the email stripped of any _Re:_ and _Fwd:_ prefixes. + + ``` + {{.SubjectBase}} + ``` +*Threading* + When threading is enabled, these attributes are available in the message + list: + + _ThreadPrefix_ + If the message is part of a thread, this will contain arrows + that represent the message tree based on _In-Reply-To_ and + _References_ headers. + + _ThreadFolded_ + Will be _true_ if the message has thread children which are + hidden by *:fold*. + + _ThreadCount_ + The number of messages in the thread. + + _ThreadUnread_ + The number of unread messages in the thread. + +*Flags* + List of message flags, not available when composing, replying nor + forwarding. This is a list of strings that may be converted to a single + string with *join*. + + ``` + {{.Flags | join ""}} + ``` + +*IsReplied*, *IsForwarded*, *HasAttachment*, *IsFlagged*, *IsRecent*, *IsUnread*, +*IsMarked*, *IsDraft* + Individual boolean flags. not available when composing, replying nor + forwarding. + + ``` + {{if .IsFlagged}}★{{end}} + ``` + +*Labels* + Message labels (for example notmuch tags). Not available when composing, + replying nor forwarding. This is a list of strings that may be converted + to a single string with *join*. + + ``` + {{.Labels | join " "}} + ``` + +*Size* + The size of the message in bytes. Not available when composing, replying + nor forwarding. It can be formatted with *humanReadable*. + + ``` + {{.Size | humanReadable}} + ``` + +*Filename* + The full path of the message file. Not available when composing, + replying nor forwarding. For the notmuch backend, it returns a random + filename if there are multiple files associated with the message. + +*Filenames* + A list of the full paths of the files associated with the message. For + maildir this is always a list with a single element. Not available when + composing, replying nor forwarding. + +*Any header value* + Any header value of the email. + + ``` + {{.Header "x-foo-bar"}} + ``` + + Any header values of the original forwarded or replied message: + + ``` + {{.OriginalHeader "x-foo-bar"}} + ``` +*Message-ID* + The message-ID of the message. + + ``` + :term b4 am {{.MessageId}} + ``` + +*MIME Type* + MIME type is available for quoted reply and forward. + + - _{{.OriginalMIMEType}}_: MIME type info of quoted mail part. Usually + _text/plain_ or _text/html_. + +*Original Message* + When using quoted reply or forward, the original message is available in a + field called _OriginalText_. + + ``` + {{.OriginalText}} + ``` + +*Signature* + The signature of the currently selected account obtained from + *signature-file* or *signature-cmd*. + + ``` + {{.Signature}} + ``` + +*Account info* + The current account name: + + ``` + {{.Account}} + ``` + + The current account's backend: + + ``` + {{.AccountBackend}} + ``` + + The current account's from address: + + ``` + {{.AccountFrom}} + {{.AccountFrom.Address}} + ``` + + Currently selected mailbox folder: + + ``` + {{.Folder}} + ``` + + Current message counts for all folders: + + ``` + {{.Recent}} {{.Unread}} {{.Exists}} + {{.RUE}} + ``` + + IANA role of the mailbox, converted to lowercase: + + ``` + {{.Role}} + ``` + + *aerc* implements two additional custom roles: A 'query' role is given + to folders from a notmuch query-map + and 'virtual' indicates a virtual node in the directory tree listing: + + ``` + {{if eq .Role "query"}}{{...}}{{else}}{{...}}{{end}} + ``` + + Current message counts for specific folders: + + ``` + {{.Recent "inbox"}} + {{.Unread "inbox" "aerc/pending"}} + {{.Exists "archive" "spam" "foo/baz" "foo/bar"}} + {{.RUE "inbox"}} + ``` + +*Status line* + + The following data will only be available in the status line templates: + + Connection state. + + ``` + {{.Connected}} + {{.ConnectionInfo}} + ``` + + General status information (e.g. filter, search) separated with + *[statusline].separator*. + + ``` + {{.ContentInfo}} + ``` + + Combination of *{{.ConnectionInfo}}* and *{{.StatusInfo}}* separated + with *[statusline].separator*. + + ``` + {{.StatusInfo}} + ``` + + General on/off information (e.g. passthrough, threading, sorting, visual + mode), separated with *[statusline].separator*. + + ``` + {{.TrayInfo}} + ``` + + Currently pressed key sequence that does not match any key binding + and/or is incomplete. + + ``` + {{.PendingKeys}} + ``` + +# TEMPLATE FUNCTIONS + +Besides the standard functions described in go's text/template documentation, +aerc provides the following additional functions: + +*wrap* + Wrap the original text to the specified number of characters per line. + + ``` + {{wrap 72 .OriginalText}} + ``` + +*quote* + Prepends each line with _"> "_. + + ``` + {{quote .OriginalText}} + ``` + +*trimSignature* + Removes the signature froma passed in mail. Quoted signatures are kept + as they are. + + ``` + {{trimSignature .OriginalText}} + ``` + +*join* + Join the provided list of strings with a separator: + + ``` + {{.To | names | join ", "}} + ``` + +*split* + Split a string into a string slice with a separator: + + ``` + {{.To | names | join ", " | split ", "}} + ``` + +*names* + Extracts the names part from a mail.Address list. If there is no name + available, the mbox (email address without @domain) is returned instead. + + ``` + {{.To | names | join ", "}} + {{index (.To | names) 0}} + ``` + +*firstnames* + Extracts the first names part from a mail.Address list. If there is no + name available, the short mbox (start of email address without @domain) + is returned instead. + + ``` + {{.To | firstnames | join ", "}} + {{index (.To | firstnames) 0}} + ``` + +*initials* + Extracts the initials from the names part from a mail.Address list. If + there is no name available, the first letter of the email address is + returned instead. + + ``` + {{.To | initials | join ", "}} + {{index (.To | initials) 0}} + ``` + +*emails* + Extracts the addresses part from a mail.Address list. + + ``` + {{.To | emails | join ", "}} + {{index (.To | emails) 0}} + ``` + +*mboxes* + Extracts the mbox part from a mail.Address list (i.e. _smith_ from + _smith@example.com_). + + ``` + {{.To | mboxes | join ", "}} + {{index (.To | mboxes) 0}} + ``` + +*shortmboxes* + Extracts the short mbox part from a mail.Address list (i.e. _smith_ from + _smith.and.wesson@example.com_). + + ``` + {{.To | shortmboxes | join ", "}} + {{index (.To | shortmboxes) 0}} + ``` + +*persons* + Formats a list of mail.Address into a list of strings containing the + human readable form of RFC5322 (e.g. _Firstname Lastname + <email@address.tld>_). + + ``` + {{.To | persons | join ", "}} + {{index (.To | persons) 0}} + ``` + +*.Attach* + Attaches a file to the message being composed. + + ``` + {{.Attach '/usr/libexec/aerc/filters/html'}} + ``` + +*exec* + Execute external command, provide the second argument to its stdin. + The command is executed with the same search *$PATH* than aerc filters + (see *aerc-config*(5) in the *FILTERS* section for more details). + + ``` + {{exec `html` .OriginalText}} + ``` + +*.Local* + Convert the date to the local timezone as specified by the locale. + + ``` + {{.Date.Local}} + ``` + +*dateFormat* + Format date and time according to the format passed as the second argument. + The format must be specified according to go's time package format. + + ``` + {{dateFormat .Date "Mon Jan 2 15:04:05 -0700 MST 2006"}} + ``` + + You can also use the _.DateAutoFormat_ method to format the date + according to *\*-time\*format* settings: + + ``` + {{.DateAutoFormat .OriginalDate.Local}} + ``` + +*now* + Return the current date as a golang time.Time object that can be + formatted with *dateFormat*. + + ``` + {{dateFormat now "Mon Jan 2 15:04:05 -0700 MST 2006"}} + ``` + +*humanReadable* + Return the human readable form of an integer value. + + ``` + {{humanReadable 3217653721}} + ``` + +*cwd* + Return the current working directory with the user home dir replaced by + _~_. + + ``` + {{cwd}} + ``` + +*compactDir* + Reduce a directory path into a compact form. The directory name will be + split with _/_ and each part will be reduced to the first letter in its + name: _INBOX/01_WORK/PROJECT_ will become _I/W/PROJECT_. + + ``` + {{compactDir .Folder}} + ``` + +*contains* + Checks if a string contains a substring. + + ``` + {{contains "<!DOCTYPE html>" .OriginalText}} + ``` + +*hasPrefix* + Checks if a string has a prefix. + + ``` + {{hasPrefix "Business" .Folder}} + ``` + +*toLower* + Convert a string to lowercase. + + ``` + {{toLower "SPECIAL OFFER!"}} + ``` + +*toUpper* + Convert a string to uppercase. + + ``` + {{toUpper "important"}} + ``` + +*replace* + Perform a regular expression substitution on the passed string. + + ``` + {{replace `(.+) - .+ at .+\..+` `$1` ((index .OriginalFrom 0).Name)}} + ``` + +*head* + Return first n characters from string. + + ``` + {{"hello" | head 2}} + ``` + +*tail* + Return last n characters from string. + + ``` + {{"hello" | tail 2}} + ``` + +*.Style* + Apply a user-defined style (see *aerc-stylesets*(7)) to a string. + + ``` + {{.Style .Account "red"}} + {{.Style .ThreadPrefix "thread"}}{{.Subject}} + ``` + +*.StyleSwitch* + Apply a user-defined style (see *aerc-stylesets*(7)) to a string if it + matches one of the associated regular expressions. If the string does + not match any of the expressions, leave it unstyled. + + ``` + {{.StyleSwitch .Subject (`^(\[[\w-]+\]\s*)?\[(RFC )?PATCH` "cyan")}} + {{.StyleSwitch (.From | names | join ", ") (case `Tim` "cyan") (case `Robin` "pink-blink") (default "blue")}} + ``` + +*.StyleMap* + Apply user-defined styles (see *aerc-stylesets*(7)) to elements of + a string list. The logic is the same than *.StyleSwitch* but works on + a list of elements. An additional *exclude* option is available to + remove the matching elements from the list. + + ``` + {{.StyleMap .Labels (exclude .Folder) (exclude `^spam$`) (case `^inbox$` "red") (case `^Archive/.*` "green") (default "blue") | join " "}} + ``` + +*version* + Returns the version of aerc, which can be useful for things like X-Mailer. + + ``` + X-Mailer: aerc {{version}} + ``` + +*match* + Check if a string matches a regular expression. This is intended for + use in conditional control flow: + + ``` + {{if match .Folder `.*/Archive-[0-9]+`}}{{humanReadable .Unread}}{{end}} + ``` + +*switch* + Do switch/case/default control flows. The switch value is compared with + regular expressions. If none of the case/default arms match, an empty + string is returned. + + ``` + {{switch .Folder (case `^INBOX$` "📥") (case `^Archive/.*` "🗃") (default "📁")}} + ``` + +*map* + Transform a string list into another one. The logic is the same than + *switch* but works on a list of elements. An additional *exclude* option + is available to remove the matching elements from the list. + + ``` + {{map .Labels (exclude .Folder) (exclude `^spam$`) (case `^inbox$` "📥") (case `^Archive/.*` "🗃") | join " "}} + ``` + +*Function chaining* + All of the template functions can be chained together if needed. + + Example: Automatic HTML parsing for text/html mime type messages + + ``` + {{if eq .OriginalMIMEType "text/html"}} + {{exec `/usr/libexec/aerc/filters/html` .OriginalText | wrap 72 | quote}} + {{else}} + {{wrap 72 .OriginalText | trimSignature | quote}} + {{end}} + ``` + +# SEE ALSO + +*aerc*(1) *aerc-config*(5) + +# AUTHORS + +Originally created by Drew DeVault and maintained by Robin Jarry who is assisted +by other open source contributors. For more information about aerc development, +see _https://sr.ht/~rjarry/aerc/_. diff --git a/doc/aerc-tutorial.7.scd b/doc/aerc-tutorial.7.scd new file mode 100644 index 0000000..9a7840f --- /dev/null +++ b/doc/aerc-tutorial.7.scd @@ -0,0 +1,157 @@ +AERC-TUTORIAL(7) + +# NAME + +aerc-tutorial - tutorial for *aerc*(1) + +# INTRODUCTION + +Welcome to aerc! This tutorial will guide you through your first steps in using +the client. This tutorial is a man page - you can read it again later with +*:help* _tutorial_ from aerc, or *man aerc-tutorial* from your terminal. + +First, let's introduce some basic keybindings. For convention, we'll use *<C-p>* +to represent _Ctrl+p_, which matches the convention used for writing keybindings +for aerc. + +*<C-p>*, *<C-n>* + Cycles to the previous or next tab + +Try using these now to switch between your message list and the tutorial. In +your message list, we use vim-style keys to get around. + +*k*, *j* + Scrolls up and down between messages + +*<C-u>*, *<C-d>* + Scrolls half a page up or down + +*g*, *G* + Selects the first or last message, respectively + +*K*, *J* + Switches between folders in the sidebar + +*<Enter>* + Opens the selected message + +You can also search the selected folder with */*, or filter with *\\ *. When +searching you can use *n* and *p* to jump to the next and previous result. +Filtering hides any non-matching message. + +# THE MESSAGE VIEWER + +Press *<Enter>* to open a message. By default, the message viewer will display +your message using *less*(1). This should also have familiar, vim-like +keybindings for scrolling around in your message. + +Multipart messages (messages with attachments, or messages with several +alternative formats) show a part selector on the bottom of the message viewer. + +*<C-k>*, *<C-j>* + Cycle between parts of a multipart message + +*q* + Close the message viewer + +To show HTML messages parts, the _text/html_ filter in your _aerc.conf_ file +(which is probably in _~/.config/aerc/_) requires *w3m* along with optional +dependencies for safer network isolation: *unshare* (from *util-linux*) or +*socksify* (from *dante-utils*). + +You can also do many tasks you could do in the message list from here, like +replying to emails, deleting the email, or view the next and previous message +(*J* and *K*). + +# COMPOSING MESSAGES + +Return to the message list by pressing *q* to dismiss the message viewer. Once +there, let's compose a message. + +*C* + Compose a new message + +*rr* + Reply-all to a message + +*rq* + Reply-all to a message, and pre-fill the editor with a quoted version of the + message being replied to + +*Rr* + Reply to a message + +*Rq* + Reply to a message, and pre-fill the editor with a quoted version of the + message being replied to + +For now, let's use *C* to compose a new message. The message composer will +appear. You should see To, From, and Subject lines, as well as your *$EDITOR*. +You can use *<Tab>* or *<C-j>* and *<C-k>* to cycle between these fields (tab +won't cycle between fields once you enter the editor, but *<C-j>* and *<C-k>* +will). + +Let's send an email to yourself. Note that the To and From headers expect RFC +5322 addresses, e.g. *John Doe <john@example.org>*, or simply +*<john@example.org>*. Separate multiple recipients with commas. Go ahead and +fill out an email, then close the editor. + +The message review screen is shown next. You have a chance now to revise the +email before it's sent. Press *y* to send the email if it looks good. + +*Note*: when using the terminal in the message view, you can summon aerc's ex +command line by using *<C-x>*. *:* is sent to the editor. + +# USING THE TERMINAL + +aerc comes with an embedded terminal, which you've already used to view and edit +emails. We can also use this for other purposes, such as referencing a git +repository while reviewing a patch. From the message list, we can use the +following keybindings to open a terminal: + +*<C-t>* + Opens a new terminal tab, running your shell + +*$*, *!* + Prompts for a command to run, then opens a new terminal tab running that + command + +*|* + Prompts for a command to run, then pipes the selected email into that + command and displays the result on a new terminal tab + +Try pressing *$* and entering _top_. You can also use the *:cd* command to +change aerc's working directory, and the directory in which new terminals run. +Use *:pwd* to see it again if you're not sure where you are. + +# ADDITIONAL NOTES + +## COMMANDS + +Every keybinding is ultimately bound to an aerc command. You can also summon the +command line by pressing *:*, then entering one of these commands. See *aerc*(1) +or *:help* for a full list of commands. + +## MESSAGE FILTERS + +When displaying messages in the message viewer, aerc will pipe them through a +message filter first. This allows you to decode messages in non-plaintext +formats, add syntax highlighting, etc. aerc ships with a few default filters: + +- _text/plain_ parts are piped through the _colorize_ built-in filter which + handles URL, quotes and diff coloring. +- _text/calendar_ is processed to be human readable text +- _text/html_ (disabled by default) can be uncommented to pipe through the + built-in _html_ filter. + +## CUSTOMIZING AERC + +Aerc is highly customizable. Review *aerc-config*(5) (or use *:help config*) to +learn more about how to add custom keybindings, install new message filters, +change its appearance and behavior, and so on. + +# AUTHORS + +Originally created by Drew DeVault and maintained by Robin Jarry who is assisted +by other open source contributors. For more information about aerc development, +see _https://sr.ht/~rjarry/aerc/_. diff --git a/doc/aerc.1.scd b/doc/aerc.1.scd new file mode 100644 index 0000000..482a9fb --- /dev/null +++ b/doc/aerc.1.scd @@ -0,0 +1,1009 @@ +AERC(1) + +# NAME + +aerc - a pretty good email client. + +# SYNOPSIS + +*aerc* [*-h*] [*-v*] [*-a* _<name>_] [*-C* _<file>_] [*-A* _<file>_] [*-B* +_<file>_] [*-I*] [*mailto:*_<...>_ | *mbox:*_<file>_ | :_<command...>_] + +For a guided tutorial, use *:help tutorial* from aerc, or *man aerc-tutorial* +from your terminal. + +# OPTIONS + +*-h*, *--help* + Show aerc usage help and exit. + +*-v*, *--version* + Print the installed version of aerc and exit. + +*-a* _<name>_++ +*--account* _<name>_ + Load only the named account, as opposed to all configured accounts. It + can also be a comma separated list of names. This option may be + specified multiple times. The account order will be preserved. + +*-C* _</path/to/aerc.conf>_++ +*--aerc-conf* _</path/to/aerc.conf>_ + Instead of using _$XDG_CONFIG_HOME/aerc/aerc.conf_ use the file at the + specified path for configuring aerc. + +*-A* _</path/to/accounts.conf>_++ +*--accounts-conf* _</path/to/accounts.conf>_ + Instead of using _$XDG_CONFIG_HOME/aerc/accounts.conf_ use the file at the + specified path for configuring accounts. + +*-B* _</path/to/binds.conf>_++ +*--binds-conf* _</path/to/binds.conf>_ + Instead of using _$XDG_CONFIG_HOME/aerc/binds.conf_ use the file at the + specified path for configuring binds. + +*-I*, *--no-ipc* + Run commands (*mailto:*_..._, *:*_<command...>_, *mbox:*_<file>_) directly + in this instance rather than over IPC in an existing aerc instance. Also + disable creation of an IPC server for subsequent aerc instances to + communicate with this one. + +*mailto:*_address[,address][?query[&query]]_ + Open the composer with the address(es) in the To field. These + addresses must not be percent encoded. + + If aerc is already running (and IPC is not disabled), the composer is + started in that instance; otherwise a new instance is started with the + composer. + + The following (optional) query parameters are supported: + +[[ *Query* +:[ *Description* +| _subject=<text>_ +: Subject line will be completed with the _<text>_ +| _body=<text>_ +: Message body will be completed with the _<text>_ +| _cc=<address>[,<address>]_ +: Cc header will be completed with the list of addresses +| _bcc=<address>[,<address>]_ +: Bcc header will be completed with the list of addresses +| _in-reply-to=<message-id>_ +: In-reply-to header will be set to the message id +| _account=<accountname>_ +: Specify the account (must be in _accounts.conf_; default is the selected account) +| _template=<template-file>_ +: Template sets the template file for creating the message + + Note that reserved characters in the queries must be percent encoded. + +*:*_<command...>_ + Run an aerc-internal command as you would in Ex-Mode. See *RUNTIME + COMMANDS* below. + + The command to be executed and its arguments can either be passed as + separate arguments in the shell (e.g., _aerc :cmd arg1 arg2_) or as a single + argument in the shell (e.g., _aerc ":cmd arg1 arg2"_). In the former case, + aerc may add quotes to the command before it is parsed in an attempt to + preserve arguments containing spaces and other special characters. In the + latter case, aerc will parse the command verbatim, as if it had been typed + directly on aerc's command line. This latter form can be helpful for + commands that don't interpret quotes in their arguments. + + If aerc is already running (and IPC is not disabled), the command is run in + that instance; otherwise a new instance is started with the command. + +*mbox:*_<file>_ + Open the specified mbox file as a virtual temporary account. + + If aerc is already running (and IPC is not disabled), the file is opened in + that instance; otherwise a new instance is started with the file. + +# RUNTIME COMMANDS + +To execute a command, press *:* to bring up the command interface. Commands may +also be bound to keys, see *aerc-binds*(5) for details. In some contexts, such +as the terminal emulator, *<c-x>* is used to bring up the command interface. + +Different commands work in different contexts, depending on the kind of tab you +have selected. + +Dynamic arguments are expanded following *aerc-templates*(7) depending on the +context. For example, if you have a message selected, the following command: + +``` +:filter -f "{{index (.From | emails) 0}}" +``` + +Will filter all messages sent by the same sender. + +Aerc stores a history of commands, which can be cycled through in command mode. +Pressing the up key cycles backwards in history, while pressing down cycles +forwards. + +## GLOBAL COMMANDS + +These commands work in any context. + +*:help* _<topic>_++ +*:man* _<topic>_ + Display one of aerc's man pages in the embedded terminal. + +*:help* *keys*++ +*:man* *keys* + Display the active key bindings in the current context. + +*:new-account* [*-t*] + Start the new account wizard. + + *-t*: Create a temporary account. Do not modify _accounts.conf_. + +*:cd* _<directory>_ + Changes aerc's current working directory. + +*:z* _<directory or zoxide query>_ + Changes aerc's current working directory using zoxide. If zoxide is not on + *$PATH*., the command will not be registered. + +*:change-tab* [*+*|*-*]_<tab name or index>_++ +*:ct* [*+*|*-*]_<tab name or index>_ + Changes the focus to the tab with the given name. If a number is given, + it's treated as an index. If the number is prepended with *+* or *-*, the number + is interpreted as a delta from the selected tab. If only a *-* is given, changes + the focus to the previously selected tab. + +*:exec* _<command>_ + Executes an arbitrary command in the background. Aerc will set the + environment variables *$account* and *$folder* when the command is + executed from an Account tab or an opened message. + + Note: commands executed in this way are not executed with the shell. + +*:echo* _<string>_ + Resolve templates in _<string>_ and print it. + +*:eml* [_<path>_]++ +*:preview* [_<path>_] + Opens an eml file and displays the message in the message viewer. + + Can also be used in the message viewer to open an rfc822 attachment or + in the composer to preview the message. + +*:pwd* + Displays aerc's current working directory in the status bar. + +*:send-keys* _<keystrokes>_ + Send keystrokes to the currently visible terminal, if any. Can be used to + control embedded editors to save drafts or quit in a safe manner. + + Here's an example of quitting a Vim-like editor: + + *:send-keys* _<Esc>:wq!<Enter>_ + + Note: when used in _binds.conf_ (see *aerc-binds*(5)), angle brackets + need to be escaped in order to make their way to the command: + + <C-q> = :send-keys \\<Esc\\>:wq!\\<Enter\\><Enter> + + This way the _<Esc>_ and the first _<Enter>_ keystrokes are passed to + *:send-keys*, while the last _<Enter>_ keystroke is executed directly, + committing the *:send-keys* command's execution. + +*:term* [_<command>..._]++ +*:terminal* [_<command>..._] + Opens a new terminal tab with a shell running in the current working + directory, or the specified command. + +*:move-tab* [_+_|_-_]_<index>_ + Moves the selected tab to the given index. If _+_ or _-_ is specified, the + number is interpreted as a delta from the selected tab. + +*:prev-tab* [_<n>_]++ +*:next-tab* [_<n>_] + Cycles to the previous or next tab in the list, repeating _<n>_ times + (default: _1_). + +*:pin-tab* + Moves the current tab to the left of all non-pinned tabs and displays + the *pinned-tab-marker* (default: _`_) to the left of the tab title. + +*:unpin-tab* + Removes the *pinned-tab-marker* from the current tab and returns the tab + to its previous location. + +*:prompt* _<prompt>_ _<command>..._ + Displays the prompt on the status bar, waits for user input, then appends + that input as the last argument to the command and executes it. The input is + passed as one argument to the command, unless it is empty, in which case no + extra argument is added. + +*:menu* [*-c* _"<shell-cmd>"_] [*-e*] [*-b*] [*-a*] [*-d*] _<aerc-cmd ...>_ + Opens a popover dialog running _sh -c "<shell-cmd>"_ (if not specified + *[general].default-menu-cmd* will be used). When the command exits, all + lines printed on its standard output will be appended to _<aerc-cmd ...>_ + and executed as a standard aerc command like *xargs*(1) would do when + used in a shell. A colon (*:*) prefix is supported for _<aerc-cmd ...>_ + but is not required. + + *:menu* can be used without an external program by setting _<shell-cmd>_ + to _-_. This also acts as a fallback in case where no _<shell-cmd>_ was + specified at all or the executable in the _<shell-cmd>_ was not found. + + *-c* _"<shell-cmd>"_ + Override *[general].default-menu-cmd*. See *aerc-config*(5) for + more details. + + *-e*: Stop executing commands on the first error. + + *-b*: Do *NOT* spawn the popover dialog. Start the commands in the + background (*NOT* in a virtual terminal). Use this if _<shell-cmd>_ is + a graphical application that does not need a terminal. + + _<shell-cmd>_ may be fed with input text using the following flags: + *-a*: All account names, one per line. E.g.: + + '<account>' LF + + *-d*: All current account directory names, one per line. E.g.: + + '<directory>' LF + + *-ad*: All directories of all accounts, one per line. E.g.: + + '<account>' '<directory>' LF + + Quotes may be added by aerc when either tokens contain special + characters. The quotes should be preserved for _<aerc-cmd ...>_. + + Examples: + + ``` + :menu -adc fzf :cf -a + :menu -c 'fzf --multi' :attach + :menu -dc 'fzf --multi' :cp + :menu -bc 'dmenu -l 20' :cf + :menu -c 'ranger --choosefiles=%f' :attach + ``` + + This may also be used in key bindings (see *aerc-binds*(5)): + + ``` + <C-p> = :menu -adc fzf :cf -a<Enter> + ``` + +*:choose* *-o* _<key>_ _<text>_ _<command>_ [*-o* _<key>_ _<text>_ _<command>_]... + Prompts the user to choose from various options. + +*:reload* [*-B*] [*-C*] [*-s* _<styleset-name>_] + Hot-reloads the config files for the key binds and general *aerc* config. + Reloading of the account config file is not supported. + + If no flags are provided, _binds.conf_, _aerc.conf_, and the current + styleset will all be reloaded. + + *-B*: Reload _binds.conf_. + + *-C*: Reload _aerc.conf_. + + *-s* _<styleset-name>_ + Load the specified styleset. + +*:suspend* + Suspends the aerc process. Some ongoing connections may be terminated. + +*:quit* [*-f*]++ +*:exit* [*-f*]++ +*:q* [*-f*] + Exits aerc. If a task is being performed that should not be interrupted + (like sending a message), a normal quit call might fail. In this case, + closing aerc can be forced with the *-f* option. + +*:redraw* + Force a full redraw of the screen. + +## MESSAGE COMMANDS + +These commands are valid in any context that has a selected message (e.g. the +message list, the message in the message viewer, etc). + +*:archive* [*-m* _<strategy>_] _<scheme>_ + Moves the selected message to the archive. The available schemes are: + + _flat_: No special structure, all messages in the archive directory + + _year_: Messages are stored in folders per year + + _month_: Messages are stored in folders per year and subfolders per month + + The *-m* option sets the multi-file strategy. See *aerc-notmuch*(5) for more + details. + +*:accept* [*-e*|*-E*] [*-s*] + Accepts an iCalendar meeting invitation. This opens a compose window + with a specially crafted attachment. Sending the email will let the + inviter know that you accepted and will likely update their calendar as + well. This will NOT add the meeting to your own calendar, that must be + done as a separate manual step (e.g. by piping the text/calendar part to + an appropriate script). + + *-e*: Forces *[compose].edit-headers* = _true_ for this message only. + + *-E*: Forces *[compose].edit-headers* = _false_ for this message only. + + *-s*: Skips the editor and goes directly to the review screen. + +*:accept-tentative* [*-e*|*-E*] [*-s*] + Accepts an iCalendar meeting invitation tentatively. + + *-e*: Forces *[compose].edit-headers* = _true_ for this message only. + + *-E*: Forces *[compose].edit-headers* = _false_ for this message only. + + *-s*: Skips the editor and goes directly to the review screen. + +*:copy* [*-dp*] [*-a* _<account>_] [*-m* _<strategy>_] _<folder>_++ +*:cp* [*-dp*] [*-a* _<account>_] [*-m* _<strategy>_] _<folder>_ + Copies the selected message(s) to _<folder>_. + + *-d*: Decrypt the message before copying. + + *-p*: Create _<folder>_ if it does not exist. + + *-a*: Copy to _<folder>_ of _<account>_. If _<folder>_ does + not exist, it will be created whether or not *-p* is used. + + *-m*: Set the multi-file strategy. See *aerc-notmuch*(5) for more details. + +*:decline* [*-e*|*-E*] [*-s*] + Declines an iCalendar meeting invitation. + + *-e*: Forces *[compose].edit-headers* = _true_ for this message only. + + *-E*: Forces *[compose].edit-headers* = _false_ for this message only. + + *-s*: Skips the editor and goes directly to the review screen. + +*:delete* [*-m* _<strategy>_]++ +*:delete-message* [*-m* _<strategy>_] + Deletes the selected message. + + *-m*: Set the multi-file strategy. See *aerc-notmuch*(5) for more details. + +*:envelope* [*-h*] [*-s* _<format-specifier>_] + Opens the message envelope in a dialog popup. + + *-h*: Show all header fields + + *-s* _<format-specifier>_ + User-defined format specifier requiring two _%s_ for the key and + value strings. Default format: _%-20.20s: %s_ + +*:recall* [*-f*] [*-e*|*-E*] [*-s*] + Opens the selected message for re-editing. Messages can only be + recalled from the postpone directory. + + *-f*: Open the message for re-editing even if it is not in the postpone + directory. Aerc remembers the folder, so the further *:postpone* call will + save the message back there. + + *-e*: Forces *[compose].edit-headers* = _true_ for this message only. + + *-E*: Forces *[compose].edit-headers* = _false_ for this message only. + + *-s*: Skips the editor and goes directly to the review screen. + + Original recalled messages are deleted if they are sent or postponed again. + In both cases you have another copy of the message somewhere. Otherwise the + recalled message is left intact. This happens if the recalled message is + discarded after editing. It can be deleted with *:rm* if it is not needed. + +*:forward* [*-A*|*-F*] [*-T* _<template-file>_] [*-e*|*-E*] [*-s*] [_<address>_...] + Opens the composer to forward the selected message to another recipient. + + *-A*: Forward the message and all attachments. + + *-F*: Forward the full message as an RFC 2822 attachment. + + *-T* _<template-file>_ + Use the specified template file for creating the initial + message body. Unless *-F* is specified, this defaults to what + is set as *forwards* in the *[templates]* section of + _aerc.conf_. + + *-e*: Forces *[compose].edit-headers* = _true_ for this message only. + + *-E*: Forces *[compose].edit-headers* = _false_ for this message only. + + *-s*: Skips the editor and goes directly to the review screen. + +*:move* [*-p*] [*-a* _<account>_] [*-m* _<strategy>_] _<folder>_++ +*:mv* [*-p*] [*-a* _<account>_] [*-m* _<strategy>_] _<folder>_ + Moves the selected message(s) to _<folder>_. + + *-p*: Create _<folder>_ if it does not exist. + + *-a*: Move to _<folder>_ of _<account>_. If _<folder>_ does + not exist, it will be created whether or not *-p* is used. + + *-m*: Set the multi-file strategy. See *aerc-notmuch*(5) for more details. + +*:patch* _<args ...>_ + Patch management sub-commands. See *aerc-patch*(7) for more details. + +*:pipe* [*-bdmps*] _<cmd>_ + Downloads and pipes the selected message into the given shell command + (executed with _sh -c "<cmd>"_), and opens a new terminal tab to show + the result. By default, the selected message part is used in the message + viewer and the full message is used in the message list. + + Operates on multiple messages when they are marked. When piping multiple + messages, aerc will write them with mbox format separators. + + *-b*: Run the command in the background instead of opening a terminal tab + + *-d*: Pipe the (full) message but decrypt it first. + + *-m*: Pipe the full message + + *-p*: Pipe just the selected message part, if applicable + + *-s*: Silently close the terminal tab after the command is completed + + This can be used to apply patch series with git: + + *:pipe -m* _git am -3_ + + When at least one marked message subject matches a patch series (e.g. + _[PATCH X/Y]_), all marked messages will be sorted by subject to ensure + that the patches are applied in order. + +*:reply* [*-acfqs*] [*-T* _<template-file>_] [*-A* _<account>_] [*-e*|*-E*] + Opens the composer to reply to the selected message. + + *-a*: Reply all + + *-c*: Close the view tab when replying. If the reply is not sent, reopen + the view tab. + + *-f:* Reply to all addresses in From and Reply-To headers. + + *-q*: Insert a quoted version of the selected message into the reply + editor. This defaults to what is set as *quoted-reply* in the *[templates]* + section of _aerc.conf_. + + *-s*: Skip opening the text editor and go directly to the review screen. + + *-T* _<template-file>_ + Use the specified template file for creating the initial + message body. + + *-A* _<account>_ + Reply with the specified account instead of the current one. + + *-e*: Forces *[compose].edit-headers* = _true_ for this message only. + + *-E*: Forces *[compose].edit-headers* = _false_ for this message only. + +*:read* [*-t*] + Marks the marked or selected messages as read. + + *-t*: Toggle the messages between read and unread. + +*:unread* [*-t*] + Marks the marked or selected messages as unread. + + *-t*: Toggle the messages between read and unread. + +*:flag* [*-t*] [*-a* | *-x* _<flag>_] + Sets (enables) a certain flag on the marked or selected messages. + + *-t*: Toggle the flag instead of setting (enabling) it. + + *-a*: Mark message as answered/unanswered. + + *-x* _<flag>_: Mark message with specific flag. + The available flags are (adapted from RFC 3501, section 2.3.2): + + _Seen_ + Message has been read + _Answered_ + Message has been answered + _Forwarded_ + Message has been forwarded + _Flagged_ + Message is flagged for urgent/special attention + _Draft_ + Message is a draft + +*:unflag* [*-t*] _<flag>_ + Operates exactly like *:flag*, defaulting to unsetting (disabling) flags. + +*:modify-labels* [_+_|_-_]_<label>_...++ +*:tag* [_+_|_-_]_<label>_... + Modify message labels (e.g. notmuch tags). Labels prefixed with a *+* are + added, those prefixed with a *-* removed. As a convenience, labels without + either operand add the specified label. + + Example: add _inbox_ and _unread_ labels, remove _spam_ label. + + *:modify-labels* _+inbox_ _-spam_ _unread_ + +*:unsubscribe* [*-e*|*-E*] [*-s*] + Attempt to automatically unsubscribe the user from the mailing list through + use of the List-Unsubscribe header. If supported, aerc may open a compose + window pre-filled with the unsubscribe information or open the unsubscribe + URL in a web browser. + + *-e*: Forces *[compose].edit-headers* = _true_ for this message only. + + *-E*: Forces *[compose].edit-headers* = _false_ for this message only. + + *-s*: Skips the editor and goes directly to the review screen. + +## MESSAGE LIST COMMANDS + +*:align* _top|center|bottom_ + Aligns the selected message. The available positions are: + + _top_: Top of the message list.++ + _center_: Center of the message list.++ + _bottom_: Bottom of the message list. + +*:disconnect*++ +*:connect* + Disconnect or reconnect the current account. This only applies to + certain email sources. + +*:clear* [*-s*] + Clears the current search or filter criteria. + + By default, the selected message will be kept. To clear the selected + message and move cursor to the top of the message list, use the *-s* flag. + + *-s*: Selects the message at the top of the message list after clearing. + +*:cf* [*-a* _<account>_] _<folder>_ + Change the folder shown in the message list to _<folder>_. + + *-a* _<account>_ + Change to _<folder>_ of _<account>_ and focus its corresponding + tab. + +*:check-mail* + Check for new mail on the selected account. Non-imap backends require + check-mail-cmd to be set in order for aerc to initiate a check for new mail. + Issuing a manual *:check-mail* command will reset the timer for automatic checking. + +*:compose* [*-H* _"<header>: <value>"_] [*-T* _<template-file>_] [*-e*|*-E*] [*-s*] [_<body>_] + Open the compose window to send a new email. The new email will be sent with + the current account's outgoing transport configuration. For details on + configuring outgoing mail delivery consult *aerc-accounts*(5). + + *-H* _"<header>: <value>"_ + Add the specified header to the message, e.g: + + *:compose -H* _"X-Custom: custom value"_ + + *-T* _<template-file>_ + Use the specified template file for creating the initial + message body. + + *-e*: Forces *[compose].edit-headers* = _true_ for this message only. + + *-E*: Forces *[compose].edit-headers* = _false_ for this message only. + + *-s*: Skips the editor and goes directly to the review screen. + + _<body>_: The initial message body. + +*:bounce* [*-A* _<account>_] _<address>_ [_<address>_...]++ +*:resend* [*-A* _<account>_] _<address>_ [_<address>_...] + Bounce the selected message or all marked messages to the specified addresses, + optionally using the specified account. This forwards the message while + preserving all the existing headers. The new sender (*From*), date (*Date*), + *Message-ID* and recipients (*To*) are prepended to the headers with the *Resent-* + prefix. For more information please refer to section 3.6.6 of RFC 2822. Note + that the bounced message is not copied over to the *sent* folder. + + Also please note that some providers (notably for instance Microsoft's + O365) do not allow sending messages with the *From* header not matching + any of the account's identities (even if *Resent-From* matches some). + +*:recover* [*-f*] [*-e*|*-E*] _<file>_ + Resume composing a message that was not sent nor postponed. The file may + not contain header data unless *[compose].edit-headers* was enabled when + originally composing the aborted message. + + *-f*: Delete the _<file>_ after opening the composer. + + *-e*: Forces *[compose].edit-headers* = _true_ for this message only. + + *-E*: Forces *[compose].edit-headers* = _false_ for this message only. + +*:filter* [_<options>_] _<terms>_... + Similar to *:search*, but filters the displayed messages to only the search + results. The search syntax is dependent on the underlying backend. + Refer to *aerc-search*(1) for details + +*:mkdir* _<name>_ + Creates a new folder for this account and changes to that folder. + +*:rmdir* [*-f*] [_<folder>_] + Removes the folder _<folder>_, or the current folder if not specified. + + By default, it will fail if the directory is non-empty (see *-f*). + + *-f* + Remove the directory even if it contains messages. + + Some programs that sync maildirs may recover deleted directories (e.g. + *offlineimap*). These can either be specially configured to properly + handle directory deletion, or special commands need to be run to delete + directories (e.g. _offlineimap --delete-folder_). + + It is possible, with a slow connection and the imap backend, that new + messages arrive in the directory before they show up - using *:rmdir* at + this moment would delete the directory and such new messages before the + user sees them. + +*:next* _<n>_[_%_]++ +*:next-message* _<n>_[_%_]++ +*:prev* _<n>_[_%_]++ +*:prev-message* _<n>_[_%_] + Selects the next (or previous) message in the message list. If specified as + a percentage, the percentage is applied to the number of messages shown on + screen and the cursor advances that far. + +*:next-folder* _<n>_++ +*:prev-folder* _<n>_ + Cycles to the next (or previous) folder shown in the sidebar, repeated + _<n>_ times (default: _1_). + +*:expand-folder* [_<folder>_]++ +*:collapse-folder* [_<folder>_] + Expands or collapses a folder when the directory tree is enabled. If no + _<folder>_ argument is specified, the currently selected folder is acted + upon. + +*:export-mbox* _<file>_ + Exports messages in the current folder to an mbox file. If there are marked + messages in the folder, only the marked ones are exported. Otherwise the + whole folder is exported. + +*:import-mbox* _<path>_ + Imports all messages from an (gzipped) mbox file to the current folder. + _<path>_ can either be a path to a file or an URL. + + Examples: + + ``` + :import-mbox ~/messages.mbox + :import-mbox https://lists.sr.ht/~rjarry/aerc-devel/patches/55634/mbox + :import-mbox https://lore.kernel.org/all/20190807155524.5112-1-steve.capper@arm.com/t.mbox.gz + ``` + +*:next-result*++ +*:prev-result* + Selects the next or previous search result. + +*:query* [*-a* _<account>_] [*-n* _name_] [*-f*] _<notmuch query>_ + Create a virtual folder using the specified top-level notmuch query. This + command is exclusive to the notmuch backend. + + *-a* _<account>_ + Change to _<folder>_ of _<account>_ and focus its corresponding + tab. + + *-n* _<name>_ + Specify the display name for the virtual folder. If not provided, + _<notmuch query>_ is used as the display name. + + *-f* + Load the query results into an already existing folder (messages + in the original folder are not deleted). + +*:search* [_<options>_] _<terms>_... + Searches the current folder for messages matching the given set of + conditions. The search syntax is dependent on the underlying backend. + Refer to *aerc-search*(1) for details. + +*:select* _<n>_++ +*:select-message* _<n>_ + Selects the _<n>_\th message in the message list (and scrolls it into + view if necessary). + +*:hsplit* [[_+_|_-_]_<n>_] +*:split* [[_+_|_-_]_<n>_] + Creates a horizontal split, showing _<n>_ messages and a message view + below the message list. If a _+_ or _-_ is prepended, the message list + size will grow or shrink accordingly. The split can be cleared by + calling *:[h]split* _0_, or just *:[h]split*. The split can be toggled + by calling split with the same (absolute) size repeatedly. For example, + *:[h]split* _10_ will create a split. Calling *:[h]split* _10_ again + will remove the split. If not specified, _<n>_ is set to an estimation + based on the user's terminal. Also see *:vsplit*. + +*:sort* [[*-r*] _<criterion>_]... + Sorts the message list by the given criteria. *-r* sorts the + immediately following criterion in reverse order. + + Available criteria: + +[[ *Criterion* +:- *Description* +| _arrival_ +:- Date and time of the messages arrival +| _cc_ +:- Addresses in the Cc field +| _date_ +:- Date and time of the message +| _from_ +:- Addresses in the From field +| _read_ +:- Presence of the read flag +| _flagged_ +:- Presence of the flagged flag +| _size_ +:- Size of the message +| _subject_ +:- Subject of the message +| _to_ +:- Addresses in the To field + +*:toggle-threads* + Toggles between message threading and the normal message list. + +*:fold* [*-at*]++ +*:unfold* [*-at*] + Collapse or un-collapse the thread children of the selected message. + If the toggle flag *-t* is set, the folded status is changed. If the + *-a* flag is set, all threads in the current view are affected. Folded + threads can be identified by _{{.Thread\*}}_ template attributes + in *[ui].index-columns*. See *aerc-config*(5) and *aerc-templates*(7) + for more details. + +*:toggle-thread-context* + Toggles between showing entire thread (when supported) and only showing + messages which match the current query / mailbox. + +*:view* [*-pb*]++ +*:view-message* [*-pb*] + Opens the message viewer to display the selected message. If the peek + flag *-p* is set, the message will not be marked as seen and ignores the + *auto-mark-read* config. If the background flag *-b* is set, the message + will be opened in a background tab. + +*:vsplit* [[_+_|_-_]_<n>_] + Creates a vertical split of the message list. The message list will be + _<n>_ columns wide, and a vertical message view will be shown to the + right of the message list. If a _+_ or _-_ is prepended, the message + list size will grow or shrink accordingly. The split can be cleared by + calling *:vsplit* _0_, or just *:vsplit*. The split can be toggled by + calling split with the same (absolute) size repeatedly. For example, + *:vsplit* _10_ will create a split. Calling *:vsplit* _10_ again will + remove the split. If not specified, _<n>_ is set to an estimation based + on the user's terminal. Also see *:split*. + +## MESSAGE VIEW COMMANDS + +*:close* + Closes the message viewer. + +*:next* _<n>_[_%_]++ +*:prev* _<n>_[_%_] + Selects the next (or previous) message in the message list. If specified as + a percentage, the percentage is applied to the number of messages shown on + screen and the cursor advances that far. + +*:next-part*++ +*:prev-part* + Cycles between message parts being shown. The list of message parts is shown + at the bottom of the message viewer. + +*:open* [*-d*] [_<args...>_] + Saves the current message part to a temporary file, then opens it. If no + arguments are provided, it will open the current MIME part with the + matching command in the *[openers]* section of _aerc.conf_. When no match + is found in *[openers]*, it falls back to the default system handler. + + *-d*: Delete the temporary file after the opener exits + + When arguments are provided: + + - The first argument must be the program to open the message part with. + Subsequent args are passed to that program. + - _{}_ will be expanded as the temporary filename to be opened. If it is + not encountered in the arguments, the temporary filename will be + appended to the end of the command. + +*:open-link* _<url>_ [_<args...>_] + Open the specified URL with an external program. The opening logic is + the same than for *:open* but the opener program will be looked up + according to the URL scheme MIME type: _x-scheme-handler/<scheme>_. + +*:save* [*-fpaA*] _<path>_ + Saves the current message part to the given path. + If the path is not an absolute path, *[general].default-save-path* from + _aerc.conf_ will be prepended to the path given. + If path ends in a trailing slash or if a folder exists on disc or if *-a* + is specified, aerc assumes it to be a directory. + When passed a directory *:save* infers the filename from the mail part if + possible, or if that fails, uses _aerc\_$DATE_. + + *-f*: Overwrite the destination whether or not it exists + + *-p*: Create any directories in the path that do not exist + + *-a*: Save all attachments. Individual filenames cannot be specified. + + *-A*: Same as *-a* but saves all the named parts, not just attachments. + +*:mark* [*-atvT*] + Marks messages. Commands will execute on all marked messages instead of the + highlighted one if applicable. The flags below can be combined as needed. + + *-a*: Apply to all messages in the current folder + + *-t*: toggle the mark state instead of marking a message + + *-v*: Enter / leave visual mark mode + + *-V*: Same as *-v* but does not clear existing selection + + *-T*: Marks the displayed message thread of the selected message. + +*:unmark* [*-at*] + Unmarks messages. The flags below can be combined as needed. + + *-a*: Apply to all messages in the current folder + + *-t*: toggle the mark state instead of unmarking a message + +*:remark* + Re-select the last set of marked messages. Can be used to chain commands + after a selection has been acted upon + +*:toggle-headers* + Toggles the visibility of the message headers. + +*:toggle-key-passthrough* + Enter or exit the *[view::passthrough]* key bindings context. See + *aerc-binds*(5) for more details. + +## MESSAGE COMPOSE COMMANDS + +*:abort* + Close the composer without sending, discarding the message in progress. + + If the text editor exits with an error (e.g. *:cq* in *vim*(1)), the + message is immediately discarded. + +*:attach* _<path>_++ +*:attach* *-m* [_<arg>_]++ +*:attach* *-r* <name> <cmd> + Attaches the file at the given path to the email. The path can contain + globbing syntax described at https://godocs.io/path/filepath#Match. + + *-m* [_<arg>_] + Runs the *file-picker-cmd* to select files to be attached. + Requires an argument when *file-picker-cmd* contains the _%s_ verb. + + *-r* <name> <cmd> + Runs the <cmd>, reads its output and attaches it as <name>. The + attachment MIME type is derived from the <name>'s extension. + +*:attach-key* + Attaches the public key for the configured account to the email. + +*:detach* [_<path>_] + Detaches the file with the given path from the composed email. If no path is + specified, detaches the first attachment instead. The path can contain + globbing syntax described at https://godocs.io/path/filepath#Match. + +*:cc* _<addresses>_++ +*:bcc* _<addresses>_ + Sets the Cc or Bcc header to the given addresses. If an editor for the header + is not currently visible in the compose window, a new one will be added. + +*:edit* [*-e*|*-E*] + (Re-)opens your text editor to edit the message in progress. This will + also allow editing the message headers. Only available from the review + screen. + + *-e*: Forces *[compose].edit-headers* = _true_ for this message only. + + *-E*: Forces *[compose].edit-headers* = _false_ for this message only. + +*:multipart* [*-d*] _<mime/type>_ + Makes the message to multipart/alternative and add the specified + _<mime/type>_ part. Only the MIME types that are configured in the + *[multipart-converters]* section of _aerc.conf_ are supported and their + related commands will be used to generate the alternate part. + + *-d*: + Remove the specified alternative _<mime/type>_ instead of + adding it. If no alternative parts are left, make the message + text/plain (i.e. not multipart/alternative). + +*:next-field*++ +*:prev-field* + Cycles between input fields in the compose window. Only available when + the text editor is visible and *[compose].edit-headers* = _false_. + +*:postpone* [*-t* _<folder>_] + Saves the current state of the message to the *postpone* folder (from + _accounts.conf_) for the current account by default. Only available from + the review screen. + + *-t*: Overrides the target folder for saving the message + + If the message was force-recalled with *:recall -f* from a different folder, + the *:postpone* command will save it back to that folder instead of the + default *postpone* folder configured in settings. Use *-t* to override that + or use *:mv* to move the saved message to a different folder. + +*:send* [*-a* _<scheme>_] [*-t* _<folder>_] + Sends the message using this accounts default outgoing transport + configuration. For details on configuring outgoing mail delivery consult + *aerc-accounts*(5). Only available from the review screen. + + *-a*: Archive the message being replied to. See *:archive* for schemes. + + *-t*: Overrides the Copy-To folder for saving the message. + +*:switch-account* _<account-name>_++ +*:switch-account* *-n*++ +*:switch-account* *-p* + Switches the account. Can be used to switch to a specific account from + its name or to cycle through accounts using the *-p* and *-n* flags. + + *-p*: switch to previous account + + *-n*: switch to next account + +*:header* [*-f*] _<name>_ [_<value>_] +*:header* [*-d*] _<name>_ + Add a new email header to the compose window. If the header is already + set and is not empty, *-f* must be used to overwrite its value. + + *-f*: Overwrite any existing header. + + *-d*: Remove the header instead of adding it. + +*:encrypt* + Encrypt the message to all recipients. If a key for a recipient cannot + be found the message will not be encrypted. + +*:sign* + Sign the message using the account's default key. If *pgp-key-id* is set + in _accounts.conf_ (see *aerc-accounts*(5)), it will be used in + priority. Otherwise, the *From* header address will be used to look for + a matching private key in the pgp keyring. + +## TERMINAL COMMANDS + +*:close* + Closes the terminal. + +# LOGGING + +Aerc does not log by default, but collecting log output can be useful for +troubleshooting and reporting issues. Redirecting stdout when invoking aerc will +write log messages to that file: + + $ aerc > aerc.log + +Persistent logging can be configured via the *log-file* and *log-level* settings +in _aerc.conf_. + +# SEE ALSO + +*aerc-config*(5) *aerc-imap*(5) *aerc-jmap*(5) *aerc-notmuch*(5) *aerc-smtp*(5) +*aerc-maildir*(5) *aerc-sendmail*(5) *aerc-search*(1) *aerc-stylesets*(7) +*aerc-templates*(7) *aerc-accounts*(5) *aerc-binds*(5) *aerc-tutorial*(7) +*aerc-patch*(7) + +# AUTHORS + +Originally created by Drew DeVault and maintained by Robin Jarry who is assisted +by other open source contributors. For more information about aerc development, +see _https://sr.ht/~rjarry/aerc/_. diff --git a/doc/carddav-query.1.scd b/doc/carddav-query.1.scd new file mode 100644 index 0000000..e12c680 --- /dev/null +++ b/doc/carddav-query.1.scd @@ -0,0 +1,103 @@ +CARDDAV-QUERY(1) + +# NAME + +carddav-query - Query a CardDAV server for contact names and emails. + +# SYNOPSIS + +*carddav-query* [*-h*] [*-l* _<limit>_] [*-v*] [*-c* _<file>_] +\[*-s* _<section>_] [*-k* _<key\_source>_] [*-C* _<key\_cred\_cmd>_] +\[*-s* _<server\_url>_] [*-u* _<username>_] [*-p* _<password>_] _<term>_ [_<term>_ ...] + +This tool has been tailored for use as *address-book-cmd* in *aerc-config*(5). + +# OPTIONS + +*-h*, *--help* + show this help message and exit + +*-v*, *--verbose* + Print debug info on stderr. + +*-l* _<limit>_, *--limit* _<limit>_ + Maximum number of results returned by the server. If the server does not + support limiting, this option will be disregarded. + + Default: _10_ + +*-c* _<file>_, *--config-file* _<file>_ + INI configuration file from which to read the CardDAV URL endpoint. + + Default: _~/.config/aerc/accounts.conf_ + +*-S* _<section>_, *--config-section* _<section>_ + INI configuration section where to find _<key\_source>_ and + _<key\_cred\_cmd>_. By default the first section where _<key\_source>_ + is found will be used. + +*-k* _<key\_source>_, *--config-key-source* _<key\_source>_ + INI configuration key to lookup in _<section>_ from _<file>_. The value + must respect the following format: + + https?://_<username>_[:_<password>_]@_<hostname>_/_<path/to/addressbook>_ + + Both _<username>_ and _<password>_ must be percent encoded. If + _<password>_ is omitted, it can be provided via *--config-key-cred-cmd* + or *--password*. + + Default: _carddav-source_ + +*-C* _<key\_cred\_cmd>_, *--config-key-cred-cmd* _<key\_cred\_cmd>_ + INI configuration key to lookup in _<section>_ from _<file>_. The value + is a command that will be executed with *sh -c* to determine + _<password>_ if it is not present in _<key\_source>_. + + Default: _carddav-source-cred-cmd_ + +*-s* _<server_url>_, *--server-url* _<server_url>_ + CardDAV server URL endpoint. Overrides configuration file. + +*-u* _<username>_, *--username* _<username>_ + Username to authenticate on the server. Overrides configuration file. + +*-p* _<password>_, *--password* _<password>_ + Password for the specified user. Overrides configuration file. + +# POSITIONAL ARGUMENTS + +_<term>_ + Search term. Will be used to search contacts from their FN (formatted + name), EMAIL, NICKNAME, ORG (company) and TITLE fields. + +# EXAMPLES + +These are excerpts of _~/.config/aerc/accounts.conf_. + +## Fastmail + +``` +[fastmail] +carddav-source = https://janedoe%40fastmail.com@carddav.fastmail.com/dav/addressbooks/user/janedoe@fastmail.com/Default +carddav-source-cred-cmd = pass fastmail.com/janedoe +address-book-cmd = carddav-query -S fastmail %s +``` + +## Gmail + +``` +[gmail] +carddav-source = https://johndoe%40gmail.com@www.googleapis.com/carddav/v1/principals/johndoe@gmail.com/lists/default +carddav-source-cred-cmd = pass gmail.com/johndoe +address-book-cmd = carddav-query -S gmail %s +``` + +# SEE ALSO + +*aerc-config*(5) + +# AUTHORS + +Originally created by Drew DeVault and maintained by Robin Jarry who is assisted +by other open source contributors. For more information about aerc development, +see _https://sr.ht/~rjarry/aerc/_. diff --git a/filters/calendar b/filters/calendar new file mode 100755 index 0000000..a808a25 --- /dev/null +++ b/filters/calendar @@ -0,0 +1,293 @@ +#!/usr/bin/awk -f +# ex: ft=awk +# +# awk filter for aerc to parse text/calendar mime-types +# +# Based on the ical2org.awk script by Eric S Fraga and updated by Guide Van +# Hoecke. Adapted to aerc by Koni Marti <koni.marti@gmail.com> +# + +BEGIN { + UIDS[0]; + people_attending[0]; + people_partstat[0]; + people_rsvp[0]; + + # use a colon to separate the type of data line from the actual contents + FS = ":"; +} + +{ + # remove carriage return from every line + gsub(/\r/, "") +} + +/^[ ]/ { + # this block deals with the continuation lines that start with a whitespace + # + line = $0 + # remove trailing whitespaces + gsub(/^[ ]/, "", line) + + # assumes continuation lines start with a space + if (indescription) { + entry = entry line + } else if (insummary) { + summary = summary line + } else if (inattendee) { + attendee = attendee line + } else if (inorganizer) { + organizer = organizer line + } else if (inlocation) { + location = location unescape(line, 0) + } +} + +/^BEGIN:VALARM/,/^END:VALARM/ { + next +} + +/^BEGIN:VEVENT/ { + # start of an event: initialize global values used for each event + start_date = ""; + end_date = ""; + entry = "" + id = "" + + indescription = 0; + insummary = 0 + inattendee = 0 + inorganizer = 0 + inlocation = 0 + + location = "" + status = "" + summary = "" + attendee = "" + organizer = "" + + rrend = "" + rcount = "" + intfreq = "" + idx = 0 + + delete people_attending; + delete people_partstat; + delete people_rsvp; +} + +/^[A-Z]/ { + if (attendee != "" && inattendee==1) + add_attendee(attendee) + + if (organizer != "" && inorganizer==1) + organizer = find_full_name(organizer) + + indescription = 0; + insummary = 0; + inattendee = 0; + inorganizer = 0; + inlocation = 0; +} + +/^DTSTART[:;]/ { + tz = get_value($0, "TZID=[^:;]*", "=") + start_date = datetimestring($2, tz); +} + +/^DTEND[:;]/ { + tz = get_value($0, "TZID=[^:;]*", "=") + end_date = datetimestring($2, tz); +} + +/^RRULE[:]/ { + freq = get_value($0, "FREQ=[^:;]*", "=") + interval = get_value($0, "INTERVAL=[^:;]*", "=") + rrend = get_value($0, "UNTIL=[^:;]*", "=") + rcount = get_value($0, "COUNT=[^:;]*", "=") + intfreq = tolower(freq) + if (interval != "") + intfreq = " +" interval intfreq +} + +/^METHOD/ { + method = $2 +} + +/^UID/ { + line = prepare($0) + id = line +} + +/^STATUS/ { + line = prepare($0) + status = line +} + +/^DESCRIPTION/ { + line = prepare($0) + entry = entry line + indescription = 1; +} + +/^SUMMARY/ { + line = prepare($0) + summary = line + insummary = 1; +} + +/^ORGANIZER/ { + organizer = $0 + inorganizer = 1; +} + +/^LOCATION/ { + line = prepare($0) + location = unescape(line, 0); + inlocation = 1; +} + +/^ATTENDEE/ { + attendee = $0 + inattendee = 1; +} + +/^END:VEVENT/ { + #output event + if (method != "") { + printf "\n This is a meeting %s\n\n", method + } + fmt = " %-14s%s\n" + is_duplicate = (id in UIDS); + if(is_duplicate == 0) { + printf fmt, "SUMMARY", unescape(summary, 0) + printf fmt, "START", start_date + printf fmt, "END", end_date + if (intfreq != "") { + printf "\n"fmt, "RECURRENCE", intfreq + if (rcount != "") + printf fmt, "COUNTS", rcount + if (rrend != "") + printf fmt, "END DATE", rrend + + } + if(location != "") + printf fmt, "LOCATION", location + if(organizer != "") + printf fmt, "ORGANIZER", organizer + if (notEmpty(people_attending)) { + printf " %-14s", "ATTENDEES " + for (idx in people_attending) { + if (idx == 1){ + printf "%s,\n", people_attending[idx] + } + else if (idx == length(people_attending)){ + printf " %-14s%s\n", "", people_attending[idx] + } + else{ + printf " %-14s%s,\n", "", people_attending[idx] + } + } + printf "\n\n %-14s\n", "DETAILED LIST:" + for (idx in people_attending) { + printf fmt, "ATTENDEE [" idx "]", people_attending[idx] + partstat = people_partstat[idx] + if (partstat != "") { + printf fmt, "", "STATUS\t" partstat + } + rsvp = people_rsvp[idx] + if (rsvp != "") { + printf fmt, "", "RSVP\t" rsvp + } + } + } + if(entry != "") + print "\n" unescape(entry, 1); + UIDS[id] = 1; + } +} + +function notEmpty(array) +{ + # "length(array) > 0" isn't POSIX-comapoptible, length accepts only strings + for (idx in array) return 1; + return 0; +} + +function prepare(line) +{ + gsub($1, "", line) + gsub(/^[: ]/, "", line) + return line +} + +function unescape(input, preserve_newlines) +{ + ret = input + gsub(/\\,/, ",", ret) + gsub(/\\;/, ";", ret) + if (preserve_newlines) + gsub(/\\n/, "\n", ret) + else + gsub(/\\n/, " ", ret) + return ret +} + + +function datetimestring(input, tzInput) +{ + timestr = input + pos = index(timestr, "T") + if (pos < 0) { + return timestr + } + + date = substr(timestr, 1, pos) + time = substr(timestr, pos+1, length(timestr)) + + year = substr(date, 1, 4) + month = substr(date, 5, 2) + day = substr(date, 7, 2) + + hour = substr(time, 1, 2) + min = substr(time, 3, 2) + sec = substr(time, 5, 2) + + return sprintf("%4d/%02d/%02d %02d:%02d:%02d %s", year, month, day, hour, min, sec, tzInput) +} + +function add_attendee(attendee) +{ + CN = find_full_name(attendee) + if (CN != "") { + idx = idx + 1 + people_attending[idx] = CN; + people_partstat[idx] = get_value(attendee, "PARTSTAT=[^;:]+", "=") + people_rsvp[idx] = get_value(attendee, "RSVP=[^;:]+", "=") + } +} + +function find_full_name(line) +{ + name = get_value(line, "CN=[^;:]+", "=") + gsub(/"[^"]*"/,"",line) + email = get_value(line, "(mailto|MAILTO):[^;]+", ":") + + if (name == "") { + return sprintf("<%s>", email) + } else { + return sprintf("%s <%s>", name, email) + } +} + +function get_value(line, regexp, sep) { + value = "" + match(line, regexp) + { + z = split(substr(line,RSTART,RLENGTH),data,sep) + if (z > 1) { + value = data[2] + } + } + return value +} diff --git a/filters/colorize.c b/filters/colorize.c new file mode 100644 index 0000000..44e18f5 --- /dev/null +++ b/filters/colorize.c @@ -0,0 +1,777 @@ +/* SPDX-License-Identifier: MIT */ +/* Copyright (c) 2023 Robin Jarry */ + +#include <ctype.h> +#include <fnmatch.h> +#include <getopt.h> +#include <regex.h> +#include <stdbool.h> +#include <stdint.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +static void usage(void) +{ + puts("usage: colorize [-h] [-8] [-s FILE] [-f FILE]"); + puts(""); + puts("Add terminal escape codes to colorize plain text email bodies."); + puts(""); + puts("options:"); + puts(" -h show this help message"); + puts(" -8 emit OSC 8 hyperlink sequences (default $AERC_OSC8_URLS)"); + puts(" -s FILE use styleset file (default $AERC_STYLESET)"); + puts(" -f FILE read from filename (default stdin)"); +} + +enum color_type { + NONE = 0, + DEFAULT, + RGB, + PALETTE, +}; + +struct color { + enum color_type type; + uint32_t rgb; + uint32_t index; +}; + +struct style { + struct color fg; + struct color bg; + bool bold; + bool blink; + bool underline; + bool reverse; + bool italic; + bool dim; + char *sequence; +}; + +__attribute__((malloc,returns_nonnull)) +static void *xmalloc(size_t s) +{ + void *ptr = malloc(s); + if (ptr == NULL) { + perror("fatal: cannot allocate buffer"); + abort(); + } + return ptr; +} + +#define BOLD "\x1b[1m" +#define RESET "\x1b[0m" +#define LONGEST_SEQ "\x1b[1;2;3;4;5;7;38;2;255;255;255;48;2;255;255;255m" + +static const char *seq(struct style *s) { + if (!s->sequence) { + char *b, *buf = xmalloc(strlen(LONGEST_SEQ) + 1); + const char *sep = ""; + + b = buf; + b += sprintf(b, "%s", "\x1b["); + if (s->bold) { + b += sprintf(b, "%s1", sep); + sep = ";"; + } + if (s->dim) { + b += sprintf(b, "%s2", sep); + sep = ";"; + } + if (s->italic) { + b += sprintf(b, "%s3", sep); + sep = ";"; + } + if (s->underline) { + b += sprintf(b, "%s4", sep); + sep = ";"; + } + if (s->blink) { + b += sprintf(b, "%s5", sep); + sep = ";"; + } + if (s->reverse) { + b += sprintf(b, "%s7", sep); + sep = ";"; + } + switch (s->fg.type) { + case NONE: + break; + case DEFAULT: + b += sprintf(b, "%s39", sep); + break; + case RGB: + b += sprintf(b, "%s38;2;%d;%d;%d", sep, + (s->fg.rgb >> 16) & 0xff, + (s->fg.rgb >> 8) & 0xff, + s->fg.rgb & 0xff); + sep = ";"; + break; + case PALETTE: + b += sprintf(b, (s->fg.index < 8) ? + "%s3%d" : "%s38;5;%d", sep, s->fg.index); + sep = ";"; + break; + } + switch (s->bg.type) { + case NONE: + break; + case DEFAULT: + b += sprintf(b, "%s49", sep); + break; + case RGB: + b += sprintf(b, "%s48;2;%d;%d;%d", sep, + (s->bg.rgb >> 16) & 0xff, + (s->bg.rgb >> 8) & 0xff, + s->bg.rgb & 0xff); + break; + case PALETTE: + b += sprintf(b, (s->bg.index < 8) ? + "%s4%d" : "%s48;5;%d", sep, s->bg.index); + break; + } + if (strcmp(buf, "\x1b[") == 0) { + b += sprintf(b, "0"); + } + sprintf(b, "m"); + s->sequence = buf; + } + return s->sequence; +} + +struct styles { + struct style url; + struct style header; + struct style signature; + struct style diff_meta; + struct style diff_chunk; + struct style diff_chunk_func; + struct style diff_add; + struct style diff_del; + struct style quote_1; + struct style quote_2; + struct style quote_3; + struct style quote_4; + struct style quote_x; +}; + +static FILE *in_file; +static bool osc8_urls; +static const char *styleset; +static struct styles styles = { + .url = { .underline = true, .fg = { .type = PALETTE, .index = 3 } }, + .header = { .bold = true, .fg = { .type = PALETTE, .index = 4 } }, + .signature = { .dim = true, .fg = { .type = PALETTE, .index = 4 } }, + .diff_meta = { .bold = true }, + .diff_chunk = { .fg = { .type = PALETTE, .index = 6 } }, + .diff_chunk_func = { .dim = true, .fg = { .type = PALETTE, .index = 6 } }, + .diff_add = { .fg = { .type = PALETTE, .index = 2 } }, + .diff_del = { .fg = { .type = PALETTE, .index = 1 } }, + .quote_1 = { .fg = { .type = PALETTE, .index = 6 } }, + .quote_2 = { .fg = { .type = PALETTE, .index = 4 } }, + .quote_3 = { .dim = true, .fg = { .type = PALETTE, .index = 6 } }, + .quote_4 = { .dim = true, .fg = { .type = PALETTE, .index = 4 } }, + .quote_x = { .dim = true, .fg = { .type = PALETTE, .index = 5 } }, +}; + +static inline bool startswith(const char *s, const char *prefix) +{ + return strncmp(s, prefix, strlen(prefix)) == 0; +} + +#define ARRAY_SIZE(a) (sizeof(a) / sizeof((a)[0])) + +static struct { const char *n; uint32_t c; } color_names[] = { + {"aliceblue", 0xf0f8ff}, {"antiquewhite", 0xfaebd7}, {"aqua", 0x00ffff}, + {"aquamarine", 0x7fffd4}, {"azure", 0xf0ffff}, {"beige", 0xf5f5dc}, + {"bisque", 0xffe4c4}, {"black", 0x000000}, {"blanchedalmond", 0xffebcd}, + {"blue", 0x0000ff}, {"blueviolet", 0x8a2be2}, {"brown", 0xa52a2a}, + {"burlywood", 0xdeb887}, {"cadetblue", 0x5f9ea0}, {"chartreuse", 0x7fff00}, + {"chocolate", 0xd2691e}, {"coral", 0xff7f50}, {"cornflowerblue", 0x6495ed}, + {"cornsilk", 0xfff8dc}, {"crimson", 0xdc143c}, {"darkblue", 0x00008b}, + {"darkcyan", 0x008b8b}, {"darkgoldenrod", 0xb8860b}, {"darkgray", 0xa9a9a9}, + {"darkgreen", 0x006400}, {"darkkhaki", 0xbdb76b}, {"darkmagenta", 0x8b008b}, + {"darkolivegreen", 0x556b2f}, {"darkorange", 0xff8c00}, {"darkorchid", 0x9932cc}, + {"darkred", 0x8b0000}, {"darksalmon", 0xe9967a}, {"darkseagreen", 0x8fbc8f}, + {"darkslateblue", 0x483d8b}, {"darkslategray", 0x2f4f4f}, {"darkturquoise", 0x00ced1}, + {"darkviolet", 0x9400d3}, {"deeppink", 0xff1493}, {"deepskyblue", 0x00bfff}, + {"dimgray", 0x696969}, {"dodgerblue", 0x1e90ff}, {"firebrick", 0xb22222}, + {"floralwhite", 0xfffaf0}, {"forestgreen", 0x228b22}, {"fuchsia", 0xff00ff}, + {"gainsboro", 0xdcdcdc}, {"ghostwhite", 0xf8f8ff}, {"gold", 0xffd700}, + {"goldenrod", 0xdaa520}, {"gray", 0x808080}, {"green", 0x008000}, + {"greenyellow", 0xadff2f}, {"honeydew", 0xf0fff0}, {"hotpink", 0xff69b4}, + {"indianred", 0xcd5c5c}, {"indigo", 0x4b0082}, {"ivory", 0xfffff0}, + {"khaki", 0xf0e68c}, {"lavender", 0xe6e6fa}, {"lavenderblush", 0xfff0f5}, + {"lawngreen", 0x7cfc00}, {"lemonchiffon", 0xfffacd}, {"lightblue", 0xadd8e6}, + {"lightcoral", 0xf08080}, {"lightcyan", 0xe0ffff}, {"lightgoldenrodyellow", 0xfafad2}, + {"lightgray", 0xd3d3d3}, {"lightgreen", 0x90ee90}, {"lightpink", 0xffb6c1}, + {"lightsalmon", 0xffa07a}, {"lightseagreen", 0x20b2aa}, {"lightskyblue", 0x87cefa}, + {"lightslategray", 0x778899}, {"lightsteelblue", 0xb0c4de}, {"lightyellow", 0xffffe0}, + {"lime", 0x00ff00}, {"limegreen", 0x32cd32}, {"linen", 0xfaf0e6}, + {"maroon", 0x800000}, {"mediumaquamarine", 0x66cdaa}, {"mediumblue", 0x0000cd}, + {"mediumorchid", 0xba55d3}, {"mediumpurple", 0x9370db}, {"mediumseagreen", 0x3cb371}, + {"mediumslateblue", 0x7b68ee}, {"mediumspringgreen", 0x00fa9a}, {"mediumturquoise", 0x48d1cc}, + {"mediumvioletred", 0xc71585}, {"midnightblue", 0x191970}, {"mintcream", 0xf5fffa}, + {"mistyrose", 0xffe4e1}, {"moccasin", 0xffe4b5}, {"navajowhite", 0xffdead}, + {"navy", 0x000080}, {"oldlace", 0xfdf5e6}, {"olive", 0x808000}, + {"olivedrab", 0x6b8e23}, {"orange", 0xffa500}, {"orangered", 0xff4500}, + {"orchid", 0xda70d6}, {"palegoldenrod", 0xeee8aa}, {"palegreen", 0x98fb98}, + {"paleturquoise", 0xafeeee}, {"palevioletred", 0xdb7093}, {"papayawhip", 0xffefd5}, + {"peachpuff", 0xffdab9}, {"peru", 0xcd853f}, {"pink", 0xffc0cb}, + {"plum", 0xdda0dd}, {"powderblue", 0xb0e0e6}, {"purple", 0x800080}, + {"rebeccapurple", 0x663399}, {"red", 0xff0000}, {"rosybrown", 0xbc8f8f}, + {"royalblue", 0x4169e1}, {"saddlebrown", 0x8b4513}, {"salmon", 0xfa8072}, + {"sandybrown", 0xf4a460}, {"seagreen", 0x2e8b57}, {"seashell", 0xfff5ee}, + {"sienna", 0xa0522d}, {"silver", 0xc0c0c0}, {"skyblue", 0x87ceeb}, + {"slateblue", 0x6a5acd}, {"slategray", 0x708090}, {"snow", 0xfffafa}, + {"springgreen", 0x00ff7f}, {"steelblue", 0x4682b4}, {"tan", 0xd2b48c}, + {"teal", 0x008080}, {"thistle", 0xd8bfd8}, {"tomato", 0xff6347}, + {"turquoise", 0x40e0d0}, {"violet", 0xee82ee}, {"wheat", 0xf5deb3}, + {"white", 0xffffff}, {"whitesmoke", 0xf5f5f5}, {"yellow", 0xffff00}, + {"yellowgreen", 0x9acd32}, +}; + +static int color_name(const char *name, uint32_t *color) +{ + for (size_t c = 0; c < ARRAY_SIZE(color_names); c++) { + if (!strcmp(name, color_names[c].n)) { + *color = color_names[c].c; + return 0; + } + } + return 1; +} + +static int parse_color(struct color *c, const char *val) +{ + uint32_t color = 0; + if (!strcmp(val, "default")) { + c->type = DEFAULT; + } else if (sscanf(val, "#%x", &color) == 1 && color <= 0xffffff) { + c->type = RGB; + c->rgb = color; + } else if (sscanf(val, "%u", &color) == 1 && color <= 256) { + c->type = PALETTE; + c->index = color; + } else if (!color_name(val, &color)) { + c->type = RGB; + c->rgb = color; + } else { + fprintf(stderr, "error: invalid color value '%s'\n", val); + return 1; + } + return 0; +} + +static int parse_bool(bool *b, const char *val) +{ + if (!strcmp(val, "true")) { + *b = true; + } else if (!strcmp(val, "false")) { + *b = false; + } else if (!strcmp(val, "toggle")) { + *b = !*b; + } else { + fprintf(stderr, "error: invalid bool value '%s'\n", val); + return 1; + } + return 0; +} + +static int set_attr(struct style *s, const char *attr, const char *val) +{ + if (!strcmp(attr, "fg")) { + if (parse_color(&s->fg, val)) + return 1; + } else if (!strcmp(attr, "bg")) { + if (parse_color(&s->fg, val)) + return 1; + } else if (!strcmp(attr, "bold")) { + if (parse_bool(&s->bold, val)) + return 1; + } else if (!strcmp(attr, "blink")) { + if (parse_bool(&s->blink, val)) + return 1; + } else if (!strcmp(attr, "underline")) { + if (parse_bool(&s->underline, val)) + return 1; + } else if (!strcmp(attr, "reverse")) { + if (parse_bool(&s->reverse, val)) + return 1; + } else if (!strcmp(attr, "italic")) { + if (parse_bool(&s->italic, val)) + return 1; + } else if (!strcmp(attr, "dim")) { + if (parse_bool(&s->dim, val)) + return 1; + } else if (!strcmp(attr, "normal")) { + s->bold = false; + s->underline = false; + s->reverse = false; + s->italic = false; + s->dim = false; + } else if (!strcmp(attr, "default")) { + s->fg.type = NONE; + s->fg.type = NONE; + } else { + fprintf(stderr, "error: invalid style attribute '%s'\n", attr); + return 1; + } + return 0; +} + +static struct {const char *n; struct style *s;} ini_objects[] = { + {"url", &styles.url}, + {"header", &styles.header}, + {"signature", &styles.signature}, + {"diff_meta", &styles.diff_meta}, + {"diff_chunk", &styles.diff_chunk}, + {"diff_chunk_func", &styles.diff_chunk_func}, + {"diff_add", &styles.diff_add}, + {"diff_del", &styles.diff_del}, + {"quote_1", &styles.quote_1}, + {"quote_2", &styles.quote_2}, + {"quote_3", &styles.quote_3}, + {"quote_4", &styles.quote_4}, + {"quote_x", &styles.quote_x}, +}; + +/* object attribute value */ +#define STYLE_LINE_FORMAT "%127[0-9A-Za-z_-*?].%127[0-9a-zA-Z_-] = %127[#a-zA-Z0-9]s" + +static int parse_styleset(void) +{ + bool in_section = false; + char buf[BUFSIZ]; + int err = 0; + FILE *f; + + if (!styleset) + return 0; + + f = fopen(styleset, "r"); + if (!f) { + perror("error: failed to open styleset"); + return 1; + } + + while (fgets(buf, sizeof(buf), f)) { + /* strip LF, CR, CRLF, LFCR */ + buf[strcspn(buf, "\r\n")] = '\0'; + if (in_section) { + char obj[128], attr[128], val[128]; + bool changed = false; + + if (sscanf(buf, STYLE_LINE_FORMAT, obj, attr, val) != 3) { + if (buf[0] == '[') { + /* start of another section */ + break; + } + continue; + } + + for (size_t o = 0; o < ARRAY_SIZE(ini_objects); o++) { + if (fnmatch(obj, ini_objects[o].n, 0)) + continue; + if (set_attr(ini_objects[o].s, attr, val)) { + err = 1; + goto end; + } + changed = true; + } + if (!changed) { + fprintf(stderr, + "error: unknown style object %s\n", + obj); + err = 1; + goto end; + } + } else if (!strcmp(buf, "[viewer]")) { + in_section = true; + } + } + +end: + fclose(f); + return err; +} + +static inline void print(const char *in) +{ + fputs(in, stdout); +} + +static inline size_t print_notabs(const char *in, size_t max_len) +{ + size_t len = 0; + while (*in != '\0' && len < max_len) { + char c = *in++; + if (c == '\t') { + /* Tabs are interpreted as cursor movement and are not + * colored like regular characters. Replace them with + * 8 spaces. */ + fputs(" ", stdout); + } else { + fputc(c, stdout); + } + len++; + } + return len; +} + +static void print_osc8(const char *url, size_t len, size_t id, bool email) { + print("\x1b]8;"); + if (url != NULL) { + printf("id=colorize-%lu;", id); + if (email) { + print("mailto://"); + } + print_notabs(url, len); + } else { + /* do not print and url id for the terminator */ + print(";"); + } + print("\x1b\\"); +} + +static void diff_chunk(const char *in) +{ + size_t len = 0; + print(seq(&styles.diff_chunk)); + while (in[len] == '@') + len++; + while (in[len] != '\0' && in[len] != '@') + len++; + while (in[len] == '@') + len++; + in += print_notabs(in, len); + print(RESET); + print(seq(&styles.diff_chunk_func)); + print_notabs(in, BUFSIZ); + print(RESET); +} + +static inline bool isurichar(char c) +{ + if (c == '\0') + return false; + if (isalnum(c)) + return true; + if (strchr("-_.,~:;/?#@!$&%*+=\"'|<>()[]", c) != NULL) + return true; + return false; +} + +#define URL_RE \ + "([a-z]{2,8})://" \ + "|(mailto:)?[[:alnum:]_+.~/-]*[[:alnum:]]@[[:alnum:]][[:alnum:].-]*[[:alnum:]]" +static regex_t url_re; + +static void urls(const char *in, struct style *ctx) +{ + /* ID of the next link to print for OSC 8. The purpose of passing + * explicit ID is to help terminal emulator with grouping of + * multi-line links in nested terminal sessions */ + static size_t url_id = 0; + + regmatch_t groups[3]; + size_t len; + bool trim; + + while (!regexec(&url_re, in, 3, groups, 0)) { + in += print_notabs(in, (size_t)groups[0].rm_so); + len = (size_t)groups[0].rm_eo - (size_t)groups[0].rm_so; + + if (groups[1].rm_so != -1) { + /* Standard URL (i.e. not mailto: nor email address). + * Regular expressions do not really cut it here and + * we need to detect opening/closing braces to handle + * markdown link syntax. */ + int paren = 0, bracket = 0, ltgt = 0; + bool emit_url = false; + size_t l = len; + + while (!emit_url && isurichar(in[l])) { + switch (in[l]) { + case '[': bracket++; l++; break; + case '(': paren++; l++; break; + case '<': ltgt++; l++; break; + case ']': + if (--bracket < 0) + emit_url = true; + else + l++; + break; + case ')': + if (--paren < 0) + emit_url = true; + else + l++; + break; + case '>': + if (--ltgt < 0) + emit_url = true; + else + l++; + break; + default: + l++; + break; + } + } + /* Heuristic to remove trailing characters that are + * valid URL characters, but typically not at the end + * of the URL */ + trim = true; + while (trim && l > len) { + switch (in[l - 1]) { + case '.': case ',': case ':': + case ';': case '?': case '!': + case '"': case '\'': case '%': + l--; + break; + default: + trim = false; + break; + } + } + if (l == len) { + /* only an URL protocol, do not colorize */ + in += print_notabs(in, len); + continue; + } + len = l; + } + print(seq(&styles.url)); + bool email = groups[2].rm_so == -1 && groups[1].rm_so == -1; + if (osc8_urls) { + print_osc8(in, len, url_id, email); + } + in += print_notabs(in, len); + if (osc8_urls) { + print_osc8(NULL, 0, url_id, email); + } + url_id++; + print(RESET); + if (ctx) { + print(seq(ctx)); + } + } + print_notabs(in, BUFSIZ); +} + +static inline void signature(const char *in) +{ + print(seq(&styles.signature)); + urls(in, &styles.signature); + print(RESET); +} + +#define HEADER_RE "^[A-Z][[:alnum:]_-]+:" +static regex_t header_re; + +static void header(const char *in) +{ + regmatch_t groups[1]; + + if (!regexec(&header_re, in, 1, groups, 0)) { + print(seq(&styles.header)); + in += print_notabs(in, (size_t)groups[0].rm_eo); + print(RESET); + } + urls(in, NULL); +} + +#define DIFF_START_RE "^(diff (--git|-up|-u)|---) [[:graph:]]" +static regex_t diff_start_re; + +#define DIFF_META_RE \ + "^(diff (--git|-up|-u)|(new|deleted) file|similarity" \ + " index|(rename|copy) (to|from)|index|---|\\+\\+\\+) " +static regex_t diff_meta_re; + +static void quote(const char *in) +{ + regmatch_t groups[8]; + struct style *s; + size_t q, level; + + q = level = 0; + while (in[q] == '>') { + level++; + q++; + if (in[q] == ' ') + q++; + } + switch (level) { + case 1: + s = &styles.quote_1; + break; + case 2: + s = &styles.quote_2; + break; + case 3: + s = &styles.quote_3; + break; + case 4: + s = &styles.quote_4; + break; + default: + s = &styles.quote_x; + break; + } + + print(seq(s)); + in += print_notabs(in, q); + if (startswith(in, "+")) { + printf("%s%s", RESET, seq(&styles.diff_add)); + print_notabs(in, BUFSIZ); + } else if (startswith(in, "-")) { + printf("%s%s", RESET, seq(&styles.diff_del)); + print_notabs(in, BUFSIZ); + } else if (!regexec(&diff_meta_re, in, 8, groups, 0)) { + print(BOLD); + print_notabs(in, BUFSIZ); + } else { + urls(in, s); + } + print(RESET); +} + +static void print_style(const char *in, struct style *s) +{ + print(seq(s)); + print_notabs(in, BUFSIZ); + print(RESET); +} + +enum state { INIT, DIFF, SIGNATURE, BODY }; + +static void colorize_line(const char *in) +{ + static enum state state = INIT; + regmatch_t groups[8]; /* enough groups to cover all expressions */ + + switch (state) { + case DIFF: + if (!strcmp(in, "-- ")) { + state = SIGNATURE; + signature(in); + } else if (startswith(in, "@@ ")) { + diff_chunk(in); + } else if (!regexec(&diff_meta_re, in, 8, groups, 0)) { + print_style(in, &styles.diff_meta); + } else if (startswith(in, "+")) { + print_style(in, &styles.diff_add); + } else if (startswith(in, "-")) { + print_style(in, &styles.diff_del); + } else if (!startswith(in, " ") && strcmp(in, "") != 0) { + state = BODY; + if (startswith(in, ">")) { + quote(in); + } else { + urls(in, NULL); + } + } else { + print_notabs(in, BUFSIZ); + } + break; + case SIGNATURE: + signature(in); + break; + default: /* BODY, INIT */ + if (!regexec(&diff_start_re, in, 8, groups, 0)) { + state = DIFF; + print_style(in, &styles.diff_meta); + } else if (!strcmp(in, "-- ")) { + state = SIGNATURE; + signature(in); + } else { + state = BODY; + if (startswith(in, ">")) { + quote(in); + } else if (!regexec(&header_re, in, 8, groups, 0)) { + header(in); + } else { + urls(in, NULL); + } + } + break; + } +} + +static int parse_args(int argc, char **argv) +{ + const char *filename = NULL, *osc8 = NULL; + int c; + + styleset = getenv("AERC_STYLESET"); + osc8 = getenv("AERC_OSC8_URLS"); + + while ((c = getopt(argc, argv, "h8s:f:")) != -1) { + switch (c) { + case '8': + osc8 = "1"; + break; + case 's': + styleset = optarg; + break; + case 'f': + filename = optarg; + break; + default: + usage(); + return 1; + } + } + if (optind < argc) { + fprintf(stderr, "%s: unexpected argument -- '%s'\n", + argv[0], argv[optind]); + usage(); + return 1; + } + if (filename == NULL || !strcmp(filename, "-")) { + in_file = stdin; + } else { + in_file = fopen(filename, "r"); + if (!in_file) { + perror("error: cannot open file"); + return 1; + } + } + osc8_urls = osc8 != NULL; + + return 0; +} + +int main(int argc, char **argv) +{ + char buf[BUFSIZ]; + int err; + + regcomp(&header_re, HEADER_RE, REG_EXTENDED); + regcomp(&diff_start_re, DIFF_START_RE, REG_EXTENDED); + regcomp(&diff_meta_re, DIFF_META_RE, REG_EXTENDED); + regcomp(&url_re, URL_RE, REG_EXTENDED); + + err = parse_args(argc, argv); + if (err) { + goto end; + } + err = parse_styleset(); + if (err) { + goto end; + } + while (fgets(buf, sizeof(buf), in_file)) { + /* strip LF, CR, CRLF, LFCR */ + buf[strcspn(buf, "\r\n")] = '\0'; + colorize_line(buf); + printf("\n"); + } +end: + if (in_file) { + fclose(in_file); + } + return err; +} diff --git a/filters/hldiff b/filters/hldiff new file mode 100755 index 0000000..dc8c727 --- /dev/null +++ b/filters/hldiff @@ -0,0 +1,46 @@ +#!/usr/bin/awk -f + +BEGIN { + bright = "\x1B[1m" + red = "\x1B[31m" + green = "\x1B[32m" + cyan = "\x1B[36m" + reset = "\x1B[0m" + + hit_diff = 0 +} +{ + if (hit_diff == 0) { + # Strip carriage returns from line + gsub(/\r/, "", $0) + + if ($0 ~ /^diff /) { + hit_diff = 1; + print bright $0 reset + } else if ($0 ~ /^.*\|.*(\+|-)/) { + left = substr($0, 0, index($0, "|")-1) + right = substr($0, index($0, "|")) + gsub(/-+/, red "&" reset, right) + gsub(/\++/, green "&" reset, right) + print left right + } else { + print $0 + } + } else { + # Strip carriage returns from line + gsub(/\r/, "", $0) + + if ($0 ~ /^-/) { + print red $0 reset + } else if ($0 ~ /^\+/) { + print green $0 reset + } else if ($0 ~ /^ /) { + print $0 + } else if ($0 ~ /^@@ (-[0-9]+,[0-9]+ \+[0-9]+,[0-9]+) @@.*/) { + sub(/^@@ (-[0-9]+,[0-9]+ \+[0-9]+,[0-9]+) @@/, cyan "&" reset) + print $0 + } else { + print bright $0 reset + } + } +} diff --git a/filters/html b/filters/html new file mode 100755 index 0000000..3f7e8ff --- /dev/null +++ b/filters/html @@ -0,0 +1,53 @@ +#!/bin/sh + +# aerc filter to view HTML emails with w3m. +# +# Networking access will be disabled unless the script is named 'html-unsafe'. +# +# If stdout is connected to a TTY, the interactive pager of w3m will be enabled. + +set -- w3m \ + -I UTF-8 -O UTF-8 -T text/html \ + -s -graph \ + -o fold_textarea=true \ + -o fold_line=true \ + -o decode_url=true \ + -o display_link=true \ + "$@" + +if [ -t 1 ]; then + # stdout is connected to a terminal, enable interactive mode + set -- "$@" -o display_borders=true + + if w3m --help 2>&1 | head -n1 | grep -q "options.*image"; then + # display inline images if support is enabled + set -- "$@" -o display_image=true -o auto_image=true + fi +else + # stdout is connected to a pager, dump output without interaction + set -- "$@" -cols 100 -dump -o disable_center=true +fi + +if ! [ "$(basename $0)" = "html-unsafe" ]; then + # attempt network isolation to prevent any phoning home by rendered emails + set -- "$@" -o no_cache=true -o use_cookie=false + + if command -v unshare >/dev/null 2>&1; then + # run the command in a separate network namespace + set -- unshare --map-root-user --net "$@" + elif command -v socksify >/dev/null 2>&1; then + # if socksify (from dante-utils) is available, use it + export SOCKS_SERVER="127.0.0.1:1" + set -- socksify "$@" + else + # best effort, use an invalid address as http proxy + set -- "$@" \ + -o use_proxy=true \ + -o http_proxy='127.0.0.1:1' \ + -o https_proxy='127.0.0.1:1' \ + -o gopher_proxy='127.0.0.1:1' \ + -o ftp_proxy='127.0.0.1:1' + fi +fi + +exec "$@" diff --git a/filters/html-unsafe b/filters/html-unsafe new file mode 120000 index 0000000..724f4d4 --- /dev/null +++ b/filters/html-unsafe @@ -0,0 +1 @@ +html \ No newline at end of file diff --git a/filters/plaintext b/filters/plaintext new file mode 100755 index 0000000..aa22eb7 --- /dev/null +++ b/filters/plaintext @@ -0,0 +1,17 @@ +#!/usr/bin/awk -f + +BEGIN { + dim = "\033[2m" + cyan = "\033[36m" + reset = "\033[0m" +} +{ + # Strip carriage returns from line + gsub(/\r/, "", $0) + + if ($0 ~ /^On .*, .* wrote:/ || $0 ~ /^>+/) { + print dim cyan $0 reset + } else { + print $0 + } +} diff --git a/filters/show-ics-details.py b/filters/show-ics-details.py new file mode 100755 index 0000000..18b2b76 --- /dev/null +++ b/filters/show-ics-details.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 + +"""Parse a vcard file given via stdin and output some details. +Currently the following details are displayed if present: + +- start date and time +- the summary information of the event +- a list of attendees +- the description of the event + +Please note: if multiple events are included in the data then only the +first one will be parsed and displayed! + +REQUIREMENTS: +- Python 3 +- Python 3 - vobject library + +To use as a filter in aerc, add the following line to your aerc.config: +text/calendar=show-ics-details.py +""" + +import re +import sys + +import vobject + + +def remove_mailto(message: str) -> str: + """Remove a possible existing 'mailto:' from the given message. + + Keyword arguments: + message -- A message string. + """ + return re.sub(r'^mailto:', '', message, flags=re.IGNORECASE) + +def extract_field(cal: vobject.icalendar.VCalendar2_0, name: str) -> str: + """Extract the desired field from the given calendar object. + + Keyword arguments: + cal -- A VCalendar 2.0 object. + name -- The field name. + """ + try: + name = name.strip() + if name == 'attendees': + attendees = [] + for attendee in cal.vevent.attendee_list: + attendees.append(remove_mailto(attendee.valueRepr()).strip()) + return ', '.join(attendees) + elif name == 'description': + return cal.vevent.description.valueRepr().strip() + elif name == 'dtstart': + return str(cal.vevent.dtstart.valueRepr()).strip() + elif name == 'organizer': + return remove_mailto(cal.vevent.organizer.valueRepr()).strip() + elif name == 'summary': + return cal.vevent.summary.valueRepr().strip() + else: + return '' + except AttributeError: + return '' + +attendees = '' +description = '' +dtstart = '' +error = '' +organizer = '' +summary = '' + +try: + cal = vobject.readOne(sys.stdin) + attendees = extract_field(cal, 'attendees') + description = extract_field(cal, 'description') + dtstart = extract_field(cal, 'dtstart') + organizer = extract_field(cal, 'organizer') + summary = extract_field(cal, 'summary') +except vobject.base.ParseError: + error = '**Sorry, but we could not parse the calendar!**' + +if error: + print(error) + print("") + +print(f"Date/Time : {dtstart}") +print(f"Summary : {summary}") +print(f"Organizer : {organizer}") +print(f"Attendees : {attendees}") +print("") +print(description) diff --git a/filters/test.sh b/filters/test.sh new file mode 100755 index 0000000..cd4aeb8 --- /dev/null +++ b/filters/test.sh @@ -0,0 +1,79 @@ +#!/bin/sh + +set -e + +here=$(dirname $0) +fail=0 +style=$(mktemp) +trap "rm -f $style" EXIT +cat >$style <<EOF +# stuff + +url.fg = red + +[viewer] +*.normal=true +*.default=true +url.underline = true # cxwlkj +header.bold= true # comment +signature.dim=true +diff_meta.bold =true +diff_chunk.dim= true +invalid . xxx = lkjfdslkjfdsqqqqqlkjdsq +diff_add.fg= #00ff00 # comment +# comment +diff_del.fg= 1 # comment2 +quote_*.fg =6 +quote_*.dim=true +quote_1.dim=false + +[user] +foo = bar +EOF +export AERC_STYLESET=$style +export AERC_OSC8_URLS=1 + +do_test() { + prefix="$1" + tool_bin="$2" + tool="$3" + vec="$4" + expected="$5" + tmp=$(mktemp) + status=0 + $prefix $tool_bin < $vec > $tmp || status=$? + if [ $status -eq 0 ] && diff -u "$expected" "$tmp"; then + echo "ok $tool < $vec > $tmp" + else + echo "error $tool < $vec > $tmp [status=$status]" + fail=1 + fi + rm -f -- "$tmp" +} + +for vec in $here/vectors/*.in; do + expected=${vec%%.in}.expected + tool=$(basename $vec | sed 's/-.*//') + tool_bin=$here/../$tool + prefix="$FILTERS_TEST_PREFIX $FILTERS_TEST_BIN_PREFIX" + # execute source directly (and omit $...BIN_PREFIX) for interpreted filters + if ! [ -f "$tool_bin" ]; then + tool_bin=$here/$tool + prefix="$FILTERS_TEST_PREFIX" + fi + do_test "$prefix" "$tool_bin" "$tool" "$vec" "$expected" + + case $tool in # additional test runs + calendar) # Awk + if awk -W posix -- '' >/dev/null 2>&1; then + # test POSIX-compatibility + do_test "$prefix" "awk -W posix -f $tool_bin" \ + "$tool (posix)" "$vec" "$expected" + else # "-W posix" is not supported and not ignored, skip test + echo "? $tool < $vec > $tmp [no '-W posix' support]" + fi + ;; + esac +done + +exit $fail diff --git a/filters/vectors/calendar-invite.expected b/filters/vectors/calendar-invite.expected new file mode 100644 index 0000000..a4f6d1c --- /dev/null +++ b/filters/vectors/calendar-invite.expected @@ -0,0 +1,11 @@ + + This is a meeting PUBLISH + + SUMMARY Test Event + START 2000/01/01 12:30:00 + END 2999/12/31 32:30:00 + LOCATION Some Location + ORGANIZER <invalid@example.org> + +A description. +With multiple lines. diff --git a/filters/vectors/calendar-invite.in b/filters/vectors/calendar-invite.in new file mode 100644 index 0000000..ff45675 --- /dev/null +++ b/filters/vectors/calendar-invite.in @@ -0,0 +1,15 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//hacksw/handcal//NONSGML v1.0//EN +CALSCALE:GREGORIAN +METHOD:PUBLISH +BEGIN:VEVENT +DESCRIPTION:A description.\nWith multiple lines. +DTEND:29991231T323000Z +DTSTAMP:19990101T000000Z +DTSTART:20000101T123000Z +LOCATION:Some Location +ORGANIZER:MAILTO:invalid@example.org +SUMMARY:Test Event +END:VEVENT +END:VCALENDAR diff --git a/filters/vectors/colorize-diff-cvs-bsd.expected b/filters/vectors/colorize-diff-cvs-bsd.expected new file mode 100644 index 0000000..6aa14da --- /dev/null +++ b/filters/vectors/colorize-diff-cvs-bsd.expected @@ -0,0 +1,31 @@ +Simple diff updating optipng to 0.7.8 + +Previous versions suffered from a buffer overflow in the GIF +decoder. + + +diff /usr/ports +commit - 4757bf975713417bec00c44757c02a21c3b6b956 +path + /usr/ports +blob - 0d3b98856492b3535664e4900d1b5582f41e6b7b +file + graphics/optipng/Makefile +--- graphics/optipng/Makefile ++++ graphics/optipng/Makefile +@@ -1,7 +1,6 @@ + COMMENT = lossless PNG optimizer +-DISTNAME = optipng-0.7.7 ++DISTNAME = optipng-0.7.8 + CATEGORIES = graphics +-REVISION = 1 + + HOMEPAGE = https://optipng.sourceforge.net/ + +blob - 34f525b3d1ea7b210ee34d1b94c98bd76c30d01f +file + graphics/optipng/distinfo +--- graphics/optipng/distinfo ++++ graphics/optipng/distinfo +@@ -1,2 +1,2 @@ +-SHA256 (optipng-0.7.7.tar.gz) = TzLyM874cLP5XTrWQov+QiTvNJCPG0Kwut+FghZlRFI= +-SIZE (optipng-0.7.7.tar.gz) = 2329555 ++SHA256 (optipng-0.7.8.tar.gz) = JaO9aEgfIVAsyqD0wT+E3PayAzjkxOjFHyzvvYUTOYw= ++SIZE (optipng-0.7.8.tar.gz) = 3294014 diff --git a/filters/vectors/colorize-diff-cvs-bsd.in b/filters/vectors/colorize-diff-cvs-bsd.in new file mode 100644 index 0000000..0c0a589 --- /dev/null +++ b/filters/vectors/colorize-diff-cvs-bsd.in @@ -0,0 +1,31 @@ +Simple diff updating optipng to 0.7.8 + +Previous versions suffered from a buffer overflow in the GIF +decoder. + + +diff /usr/ports +commit - 4757bf975713417bec00c44757c02a21c3b6b956 +path + /usr/ports +blob - 0d3b98856492b3535664e4900d1b5582f41e6b7b +file + graphics/optipng/Makefile +--- graphics/optipng/Makefile ++++ graphics/optipng/Makefile +@@ -1,7 +1,6 @@ + COMMENT = lossless PNG optimizer +-DISTNAME = optipng-0.7.7 ++DISTNAME = optipng-0.7.8 + CATEGORIES = graphics +-REVISION = 1 + + HOMEPAGE = https://optipng.sourceforge.net/ + +blob - 34f525b3d1ea7b210ee34d1b94c98bd76c30d01f +file + graphics/optipng/distinfo +--- graphics/optipng/distinfo ++++ graphics/optipng/distinfo +@@ -1,2 +1,2 @@ +-SHA256 (optipng-0.7.7.tar.gz) = TzLyM874cLP5XTrWQov+QiTvNJCPG0Kwut+FghZlRFI= +-SIZE (optipng-0.7.7.tar.gz) = 2329555 ++SHA256 (optipng-0.7.8.tar.gz) = JaO9aEgfIVAsyqD0wT+E3PayAzjkxOjFHyzvvYUTOYw= ++SIZE (optipng-0.7.8.tar.gz) = 3294014 diff --git a/filters/vectors/colorize-diff-cvs-bsd2.expected b/filters/vectors/colorize-diff-cvs-bsd2.expected new file mode 100644 index 0000000..560208b --- /dev/null +++ b/filters/vectors/colorize-diff-cvs-bsd2.expected @@ -0,0 +1,74 @@ +On Mon, Nov 13 2023, Jeremie Courreges-Anglas <]8;id=colorize-0;mailto://jca@wxcvbn.org\jca@wxcvbn.org]8;;\> wrote: +> We need to disable optimization for python3 ports. Even with a fixed +> path to libclang_rt.profile.etc, I get: +> +> ld.lld: error: relocation R_X86_64_PC32 cannot be used against symbol '__profd_isdigit'; recompile with -fPIC +> +> ok? + +The previous diff had an obvious issue spotted by ajacoutot@, thanks! + + +Index: Makefile.inc +=================================================================== +RCS file: /home/cvs/ports/lang/python/Makefile.inc,v +diff -u -p -r1.159 Makefile.inc +--- Makefile.inc 26 Sep 2023 12:02:03 -0000 1.159 ++++ Makefile.inc 13 Nov 2023 16:11:40 -0000 +@@ -130,7 +130,9 @@ CONFIGURE_ARGS += --with-lto + . if ${MACHINE_ARCH} != "arm" && ${MACHINE_ARCH} != "powerpc" + # On armv7, clang errors out due to lack of memory. + # On powerpc, the python binary would crash by "Segmentation fault". +-CONFIGURE_ARGS += --enable-optimizations ++# XXX lld from llvm-16 errors out with: ++# ld.lld: error: relocation R_X86_64_PC32 cannot be used against symbol '__profd_isdigit'; recompile with -fPIC ++#CONFIGURE_ARGS += --enable-optimizations + . endif + . endif + TEST_IS_INTERACTIVE = Yes +Index: 3.10/Makefile +=================================================================== +RCS file: /home/cvs/ports/lang/python/3.10/Makefile,v +diff -u -p -r1.36 Makefile +--- 3.10/Makefile 1 Sep 2023 18:48:06 -0000 1.36 ++++ 3.10/Makefile 13 Nov 2023 15:53:49 -0000 +@@ -4,6 +4,7 @@ + # Python itself. + + FULL_VERSION = 3.10.13 ++REVISION = 0 + SHARED_LIBS = python3.10 0.0 + VERSION_SPEC = >=3.10,<3.11 + PORTROACH = limit:^3\.10 +Index: 3.11/Makefile +=================================================================== +RCS file: /home/cvs/ports/lang/python/3.11/Makefile,v +diff -u -p -r1.12 Makefile +--- 3.11/Makefile 20 Oct 2023 09:18:48 -0000 1.12 ++++ 3.11/Makefile 13 Nov 2023 15:54:05 -0000 +@@ -4,6 +4,7 @@ + # Python itself. + + FULL_VERSION = 3.11.6 ++REVISION = 0 + SHARED_LIBS = python3.11 0.0 + VERSION_SPEC = >=3.11,<3.12 + PORTROACH = limit:^3\.11 +Index: 3.9/Makefile +=================================================================== +RCS file: /home/cvs/ports/lang/python/3.9/Makefile,v +diff -u -p -r1.42 Makefile +--- 3.9/Makefile 1 Sep 2023 18:50:44 -0000 1.42 ++++ 3.9/Makefile 13 Nov 2023 15:53:58 -0000 +@@ -4,6 +4,7 @@ + # Python itself. + + FULL_VERSION = 3.9.18 ++REVISION = 0 + SHARED_LIBS = python3.9 0.0 + VERSION_SPEC = >=3.9,<3.10 + PORTROACH = limit:^3\.9 + + +--  +jca | PGP : 0x1524E7EE / 5135 92C1 AD36 5293 2BDF DDCC 0DFA 74AE 1524 E7EE diff --git a/filters/vectors/colorize-diff-cvs-bsd2.in b/filters/vectors/colorize-diff-cvs-bsd2.in new file mode 100644 index 0000000..681b6a9 --- /dev/null +++ b/filters/vectors/colorize-diff-cvs-bsd2.in @@ -0,0 +1,74 @@ +On Mon, Nov 13 2023, Jeremie Courreges-Anglas <jca@wxcvbn.org> wrote: +> We need to disable optimization for python3 ports. Even with a fixed +> path to libclang_rt.profile.etc, I get: +> +> ld.lld: error: relocation R_X86_64_PC32 cannot be used against symbol '__profd_isdigit'; recompile with -fPIC +> +> ok? + +The previous diff had an obvious issue spotted by ajacoutot@, thanks! + + +Index: Makefile.inc +=================================================================== +RCS file: /home/cvs/ports/lang/python/Makefile.inc,v +diff -u -p -r1.159 Makefile.inc +--- Makefile.inc 26 Sep 2023 12:02:03 -0000 1.159 ++++ Makefile.inc 13 Nov 2023 16:11:40 -0000 +@@ -130,7 +130,9 @@ CONFIGURE_ARGS += --with-lto + . if ${MACHINE_ARCH} != "arm" && ${MACHINE_ARCH} != "powerpc" + # On armv7, clang errors out due to lack of memory. + # On powerpc, the python binary would crash by "Segmentation fault". +-CONFIGURE_ARGS += --enable-optimizations ++# XXX lld from llvm-16 errors out with: ++# ld.lld: error: relocation R_X86_64_PC32 cannot be used against symbol '__profd_isdigit'; recompile with -fPIC ++#CONFIGURE_ARGS += --enable-optimizations + . endif + . endif + TEST_IS_INTERACTIVE = Yes +Index: 3.10/Makefile +=================================================================== +RCS file: /home/cvs/ports/lang/python/3.10/Makefile,v +diff -u -p -r1.36 Makefile +--- 3.10/Makefile 1 Sep 2023 18:48:06 -0000 1.36 ++++ 3.10/Makefile 13 Nov 2023 15:53:49 -0000 +@@ -4,6 +4,7 @@ + # Python itself. + + FULL_VERSION = 3.10.13 ++REVISION = 0 + SHARED_LIBS = python3.10 0.0 + VERSION_SPEC = >=3.10,<3.11 + PORTROACH = limit:^3\.10 +Index: 3.11/Makefile +=================================================================== +RCS file: /home/cvs/ports/lang/python/3.11/Makefile,v +diff -u -p -r1.12 Makefile +--- 3.11/Makefile 20 Oct 2023 09:18:48 -0000 1.12 ++++ 3.11/Makefile 13 Nov 2023 15:54:05 -0000 +@@ -4,6 +4,7 @@ + # Python itself. + + FULL_VERSION = 3.11.6 ++REVISION = 0 + SHARED_LIBS = python3.11 0.0 + VERSION_SPEC = >=3.11,<3.12 + PORTROACH = limit:^3\.11 +Index: 3.9/Makefile +=================================================================== +RCS file: /home/cvs/ports/lang/python/3.9/Makefile,v +diff -u -p -r1.42 Makefile +--- 3.9/Makefile 1 Sep 2023 18:50:44 -0000 1.42 ++++ 3.9/Makefile 13 Nov 2023 15:53:58 -0000 +@@ -4,6 +4,7 @@ + # Python itself. + + FULL_VERSION = 3.9.18 ++REVISION = 0 + SHARED_LIBS = python3.9 0.0 + VERSION_SPEC = >=3.9,<3.10 + PORTROACH = limit:^3\.9 + + +-- +jca | PGP : 0x1524E7EE / 5135 92C1 AD36 5293 2BDF DDCC 0DFA 74AE 1524 E7EE diff --git a/filters/vectors/colorize-patch.expected b/filters/vectors/colorize-patch.expected new file mode 100644 index 0000000..fe92099 --- /dev/null +++ b/filters/vectors/colorize-patch.expected @@ -0,0 +1,49 @@ +From: Robin Jarry <]8;id=colorize-0;mailto://robin@jarry.cc\robin@jarry.cc]8;;\> +Date: Mon, 26 Dec 2022 17:02:14 +0100 +Subject: [PATCH aerc] doc: fix numbered lists + +According to scdoc(5), numbered lists start with a period. + +Fixes: af63bd0188d1 ("doc: homogenize scdoc markup") +Signed-off-by: Robin Jarry <]8;id=colorize-1;mailto://robin@jarry.cc\robin@jarry.cc]8;;\> +--- + doc/aerc-stylesets.7.scd | 18 +++++++++--------- + 1 file changed, 9 insertions(+), 9 deletions(-) + +diff --git a/doc/aerc-stylesets.7.scd b/doc/aerc-stylesets.7.scd +index d82ba7cf8163..34bbf4af0fc5 100644 +--- a/doc/aerc-stylesets.7.scd ++++ b/doc/aerc-stylesets.7.scd +@@ -180,20 +180,20 @@ that style applies, unless overridden by a higher layer. + + The order that *msglist_\** styles are applied in is, from first to last: + +-1. *msglist_default* +-2. *msglist_unread* +-3. *msglist_read* +-4. *msglist_flagged* +-5. *msglist_deleted* +-6. *msglist_marked* ++. *msglist_default* ++. *msglist_unread* ++. *msglist_read* ++. *msglist_flagged* ++. *msglist_deleted* ++. *msglist_marked* + + So, the marked style will override all other msglist styles. + + The order for *dirlist_\** styles is: + +-1. *dirlist_default* +-2. *dirlist_unread* +-3. *dirlist_recent* ++. *dirlist_default* ++. *dirlist_unread* ++. *dirlist_recent* + + ## COLORS + +--  +2.39.0 + diff --git a/filters/vectors/colorize-patch.in b/filters/vectors/colorize-patch.in new file mode 100644 index 0000000..48e12d8 --- /dev/null +++ b/filters/vectors/colorize-patch.in @@ -0,0 +1,49 @@ +From: Robin Jarry <robin@jarry.cc> +Date: Mon, 26 Dec 2022 17:02:14 +0100 +Subject: [PATCH aerc] doc: fix numbered lists + +According to scdoc(5), numbered lists start with a period. + +Fixes: af63bd0188d1 ("doc: homogenize scdoc markup") +Signed-off-by: Robin Jarry <robin@jarry.cc> +--- + doc/aerc-stylesets.7.scd | 18 +++++++++--------- + 1 file changed, 9 insertions(+), 9 deletions(-) + +diff --git a/doc/aerc-stylesets.7.scd b/doc/aerc-stylesets.7.scd +index d82ba7cf8163..34bbf4af0fc5 100644 +--- a/doc/aerc-stylesets.7.scd ++++ b/doc/aerc-stylesets.7.scd +@@ -180,20 +180,20 @@ that style applies, unless overridden by a higher layer. + + The order that *msglist_\** styles are applied in is, from first to last: + +-1. *msglist_default* +-2. *msglist_unread* +-3. *msglist_read* +-4. *msglist_flagged* +-5. *msglist_deleted* +-6. *msglist_marked* ++. *msglist_default* ++. *msglist_unread* ++. *msglist_read* ++. *msglist_flagged* ++. *msglist_deleted* ++. *msglist_marked* + + So, the marked style will override all other msglist styles. + + The order for *dirlist_\** styles is: + +-1. *dirlist_default* +-2. *dirlist_unread* +-3. *dirlist_recent* ++. *dirlist_default* ++. *dirlist_unread* ++. *dirlist_recent* + + ## COLORS + +-- +2.39.0 + diff --git a/filters/vectors/colorize-quotes.expected b/filters/vectors/colorize-quotes.expected new file mode 100644 index 0000000..16a29c2 --- /dev/null +++ b/filters/vectors/colorize-quotes.expected @@ -0,0 +1,65 @@ +Foo Bar, xxxxx: +> Lorem ipsum dolor sit amet, insolens adolescens ne usu? In pri denique +> argumentum, te autem decore convenire mea! Duo nisl esse an, aliquid +> conceptam sea cu. Ignota copiosae gubergren ad est, ut illum doming vocibus +> sed. Et vis nulla expetendis mediocritatem, errem option gloriatur at nam? +> Brute vidisse corpora ut his, sonet omnesque adipiscing ea quo, cum ea errem +> aliquip reformidans? + +Magna delicatissimi ei vel? Quem petentium scribentur eum ne? Et inani debet +cetero mea, sint conceptam efficiendi mel te. Qui ut senserit interesset, per +nibh petentium at! Sit docendi laboramus ei, animal insolens ad mea. + +>> Nostrud alienum nec in, illum errem audiam no per! Saepe alterum vis ea! Ei +>> quis minim ius, ut eos mandamus salutandi. Lorem facilisis in nam, ridens +>> principes sadipscing et eum, pri graecis singulis ut. Mea dolor primis +>> impetus in, his epicurei tacimates id, vis labitur suscipit ad. +> Erat alienum interpretaris has et, te vim aliquam molestie. Nam vivendum +> facilisis qualisque at, ex his mucius qualisque! Fabulas lucilius adversarium +> eu his. Cu soluta inermis accusata usu, his nulla dolore ne, vis id semper +> detracto sententia <]8;id=colorize-0;https://foobar.com\https://foobar.com]8;;\> && "]8;id=colorize-1;https://foobaz.org/\https://foobaz.org/]8;;\". +> +> Error libris deleniti ea mei, vis at elit probo munere, his sint unum +> albucius ex. []8;id=colorize-2;https://pouet.com/oksuper\https://pouet.com/oksuper]8;;\](]8;id=colorize-3;https://pouet.com/oksuper\https://pouet.com/oksuper]8;;\). + +Graece definiebas scripserit ne est? Nec nonumes explicari contentiones ne, +vocent iuvaret placerat no vix. Nec et partem salutandi deseruisse, his no +possim malorum pericula. Te quando reprehendunt nam, at consul sadipscing vel? +Velit possim aliquando ei per, ne simul quodsi antiopam sea, ullum choro +facilisi et pri http:// or https://! + +> Dico soleat partem ea pro, ad vix impetus splendide. Primis melius principes +> pri ad, tacimates pertinacia ei pro? Appareat atomorum oportere at nam, eu +> per quod minim reprimique, ornatus graecis ad vel. Malis vulputate ea qui, +> eum tacimates recteque et, usu ea dolore vidisse. Brute mediocrem molestiae +> sed te. No stet prompta pri, rebum populo nominati eos te. +> +> diff --git a/foo b/foo +> index 4b0fe8dded3a..518b67134639 100644 +> --- a/foo +> +++ b/foo +> @@ -131,6 +131,83 @@ func pouet() int { +> err := doThis() +>  +> - err2 := doThat() +> + err2 := notDoThat() +>  +> if err != nil || err2 != nil { + +Id vix referrentur philosophia, veri labores an nec. Noster denique no duo, sit +ei diam inermis vocibus! Mutat principes ex pro, at ]8;id=colorize-4;mailto://~rjarry/aerc-devel@lists.sr.ht\~rjarry/aerc-devel@lists.sr.ht]8;;\. +Has putent verterem constituto ex, tale electram duo at! Ei nulla lucilius +intellegat nam, pro quod epicuri dissentiet ut, omnis voluptatibus definitiones +vim at []8;id=colorize-5;irc://foo.bar\irc://foo.bar]8;;\] <]8;id=colorize-6;mailto://jeanpierre@foobaz.org\jeanpierre@foobaz.org]8;;\>. + +]8;id=colorize-7;https://git-man-page-generator.lokaltog.net/#Y2xhcCQkY29tbWFuZA==\https://git-man-page-generator.lokaltog.net/#Y2xhcCQkY29tbWFuZA==]8;;\ + +Eam mundi libris debitis ad, eam regione numquam at. Eum omnes bonorum eu, +oporteat assueverit disputationi nam ne, nonumes iracundia mea ad! Duo libris +recusabo id, ceteros salutatus inciderint vim ea. Et graeco reformidans vel? Ei +has labore quidam ]8;id=colorize-8;https://foobaz.com/ooo<uuuu>okf\https://foobaz.com/ooo<uuuu>okf]8;;\? + +>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> sympa, non? + +--  +Batman diff --git a/filters/vectors/colorize-quotes.in b/filters/vectors/colorize-quotes.in new file mode 100644 index 0000000..2e71786 --- /dev/null +++ b/filters/vectors/colorize-quotes.in @@ -0,0 +1,65 @@ +Foo Bar, xxxxx: +> Lorem ipsum dolor sit amet, insolens adolescens ne usu? In pri denique +> argumentum, te autem decore convenire mea! Duo nisl esse an, aliquid +> conceptam sea cu. Ignota copiosae gubergren ad est, ut illum doming vocibus +> sed. Et vis nulla expetendis mediocritatem, errem option gloriatur at nam? +> Brute vidisse corpora ut his, sonet omnesque adipiscing ea quo, cum ea errem +> aliquip reformidans? + +Magna delicatissimi ei vel? Quem petentium scribentur eum ne? Et inani debet +cetero mea, sint conceptam efficiendi mel te. Qui ut senserit interesset, per +nibh petentium at! Sit docendi laboramus ei, animal insolens ad mea. + +>> Nostrud alienum nec in, illum errem audiam no per! Saepe alterum vis ea! Ei +>> quis minim ius, ut eos mandamus salutandi. Lorem facilisis in nam, ridens +>> principes sadipscing et eum, pri graecis singulis ut. Mea dolor primis +>> impetus in, his epicurei tacimates id, vis labitur suscipit ad. +> Erat alienum interpretaris has et, te vim aliquam molestie. Nam vivendum +> facilisis qualisque at, ex his mucius qualisque! Fabulas lucilius adversarium +> eu his. Cu soluta inermis accusata usu, his nulla dolore ne, vis id semper +> detracto sententia <https://foobar.com> && "https://foobaz.org/". +> +> Error libris deleniti ea mei, vis at elit probo munere, his sint unum +> albucius ex. [https://pouet.com/oksuper](https://pouet.com/oksuper). + +Graece definiebas scripserit ne est? Nec nonumes explicari contentiones ne, +vocent iuvaret placerat no vix. Nec et partem salutandi deseruisse, his no +possim malorum pericula. Te quando reprehendunt nam, at consul sadipscing vel? +Velit possim aliquando ei per, ne simul quodsi antiopam sea, ullum choro +facilisi et pri http:// or https://! + +> Dico soleat partem ea pro, ad vix impetus splendide. Primis melius principes +> pri ad, tacimates pertinacia ei pro? Appareat atomorum oportere at nam, eu +> per quod minim reprimique, ornatus graecis ad vel. Malis vulputate ea qui, +> eum tacimates recteque et, usu ea dolore vidisse. Brute mediocrem molestiae +> sed te. No stet prompta pri, rebum populo nominati eos te. +> +> diff --git a/foo b/foo +> index 4b0fe8dded3a..518b67134639 100644 +> --- a/foo +> +++ b/foo +> @@ -131,6 +131,83 @@ func pouet() int { +> err := doThis() +> +> - err2 := doThat() +> + err2 := notDoThat() +> +> if err != nil || err2 != nil { + +Id vix referrentur philosophia, veri labores an nec. Noster denique no duo, sit +ei diam inermis vocibus! Mutat principes ex pro, at ~rjarry/aerc-devel@lists.sr.ht. +Has putent verterem constituto ex, tale electram duo at! Ei nulla lucilius +intellegat nam, pro quod epicuri dissentiet ut, omnis voluptatibus definitiones +vim at [irc://foo.bar] <jeanpierre@foobaz.org>. + +https://git-man-page-generator.lokaltog.net/#Y2xhcCQkY29tbWFuZA== + +Eam mundi libris debitis ad, eam regione numquam at. Eum omnes bonorum eu, +oporteat assueverit disputationi nam ne, nonumes iracundia mea ad! Duo libris +recusabo id, ceteros salutatus inciderint vim ea. Et graeco reformidans vel? Ei +has labore quidam https://foobaz.com/ooo<uuuu>okf? + +>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> sympa, non? + +-- +Batman diff --git a/filters/vectors/wrap-asciiart.expected b/filters/vectors/wrap-asciiart.expected new file mode 120000 index 0000000..5ea3f40 --- /dev/null +++ b/filters/vectors/wrap-asciiart.expected @@ -0,0 +1 @@ +wrap-asciiart.in \ No newline at end of file diff --git a/filters/vectors/wrap-asciiart.in b/filters/vectors/wrap-asciiart.in new file mode 100644 index 0000000..9c6c9d7 --- /dev/null +++ b/filters/vectors/wrap-asciiart.in @@ -0,0 +1,31 @@ +# A guide to understanding flow charts (presented in flow chart form) + + +-----+ + |START| + +-----+ + | + ________v________ + / \ +-----+ + / DO YOU UNDERSTAND \--------------------------------------------->|GOOD!|----------+ + \ FLOW CHARTS? /yes +-----+ v + \_________________/ ^ +--------------+ 6 drinks +-------------------+ + |no | |LET'S GO DRINK|------------->|HEY, I SHOULD TRY | + _________v_________ ______________________ | +--------------+ |INSTALLING FREEBSD!| + / \ / \ | ^ +-------------------+ +/ OKAY, YOU SEE THE \------------------>/ ... AND YOU CAN SEE \---+ | +\ LINE LABELED "YES"? /yes \ THE ONES LABELED "NO"? /yes +--------+ + \___________________/ \______________________/ |SCREW IT| + |no |no +--------+ + | | ^ + ________v_________ _________v__________ | + / \ +-----------+ / \ +------------+ + / BUT YOU SEE THE \-->|WAIT, WHAT?| / BUT YOU JUST \------------|(THAT WASN'T| + \ ONES LABELED "NO"? /yes+-----------+ \ FOLLOWED THEM TWICE! /yes |A QUESTION) | + \__________________/ \____________________/ +------------+ + |no |no ^ + v | | + +-------+ +----------+ | | + |LISTEN.|--------->|I HATE YOU| +------------------------------+ + +-------+ +----------+ + +Source: https://xkcd.com/518/ diff --git a/filters/vectors/wrap-cjk.expected b/filters/vectors/wrap-cjk.expected new file mode 100644 index 0000000..eebe443 --- /dev/null +++ b/filters/vectors/wrap-cjk.expected @@ -0,0 +1,75 @@ +# Chinese + +涾一祄琌幄睟亍瑳褭冘,蚳乜枲挻媗楪乇酳冘仡。毰侘癿澺搥諨厊一釽埸貣侗夯昺丌,筥 +跐亍阽疪暔芫氿乇邳嵺苵殳刳。紬一炰郴啿蓱亍銍瑿毌,脧乜俉舲棬罧屮碞冇汃。殳珨攽 +祪陎齵僆裞扠尥畇妧矻杶芏瑗屮抰廑嫊。罣一洒剫鈂搣丌餈蹅圠,絇乜苭梇睌痯乇獃气 +仚。琇一峓趹絟痯丌慱懌夬,聇乇羍耛堙祽亍瑱冇圣。釽炖灱鴞飶橝邗一絘珴彧呥仩峛 +乇,嗂揤兀泲垛嗒侇汃亍沭鉽毘仈姈。舺一柼婐喑媐乜漮蕣丱,蚹兀玵窔棤跬丌蒔丮庂。 + +夬珓芶婤迠靃萰輈匢朳姷妡泆坨忻鈱乇沴瞁翜。丏茯炓笵苹靃瑒窣囡囡胘甹厒竻汦壾兀拊 +箤雸。夬栴刲烸垌鑫粲綍仵汔胍尪怓枟佁搹丌帔摓椴。惔一帡梜淼蒍乜嵼懁尐,崞乇虼軗 +鈚頍屮髧殳卌。厹迼岪孲茌瓥旓惷艸厊郕巠怓昃伻艀亍欥跿楪。挻一峊娹晪誄兀銆濏仂, +軜屮昦捵棝幋屮搿尐尕。郴一羑牾睄趓屮蒝徻亓,剭丌怷祩硤酯丌鉺卬帄。 + +夃垸弢莗枹蠷僉椴扥阣胇玔抪岟杍稗兀泂蓒媐。雂怮吇謔詵鋾扡一蛬焐浰妽尻狪亍,赨鈀 +丌欥赲蜎屇阞丌芛獑罘冇芧。庴一茇紬鈏溦乇漭錉冘,梪兀秖軜湱僉乜鄣殳氻。掾沝仵螛 +塣螇艼一觝衒脁屇宁庛乇,朠棔兀屇挍搷咂仚亍怭馺俍卬岠。冘舯枎翋怹鸂雎祹朾朻耎肒 +泀狖帊萿亍呯榡搧。絓芩朿褢楩瘼妅一揘崚桏厒屴俬乜,摁嵫乜怬娀廆姁氶乇抶蓗柲丮 +芼。袼一虴梮喵搨乜蜑廦丱,紨丌枲翊傔葑亍漊气圣。剫一耏烸蛪鈳亍膇鋻旡,酕丌柣啒 +晲裬乜緄仉屴。渃泆妅嶵酨叡玎一罥桷捊劼汃癹乇,煐厤亍泝籺萻咍仚兀矼墔柧尐抾。 + +偢一苲猞敪窣乜漊憴尐,眥兀屌笰湒楢兀槊巿氶。旡秝盰絅笁鼞楜雺虍阢垌犿昈姎抌壼乜 +矼褙蛖。厹茦皯唲釓鸂瘃暊阠扡咠忨妺沷佖閟丌肏睾睒。跁一茌掑褁摀亍輎閾巿,紻丌柜 +崌蛝雎乜慺厹夯。氪一罘粣湒僁兀嫛踼爿,脕亍羑翊羡犐兀戩勼扐。蛅一庢粣缾艅乜嶀篣 +仈,秶乜紃荾埵寖乇戩丮圣。仉罜怢庴氠齵巰輆刐庄宬岒盰矷灴葝丌昍綩銢。冇倎侚捥柼 +瓛貄媱厊艼柎卣弣芠玗楈乜抭蜺嗊。 + +# Japanese + +応ルモユム格分ぎっ中側な例披ロオソリ盛申みこへ教更反トホ写3録47男ソ朝開ク中 +属ノ速法む米指ふさりこ考震フ明訟ばだ著敢渓窮胡よ。予ムヒト選技32浴ぜが子円もみ +歳旅せてうけ実石ミサアト未9首放レミ募権大ん同市体づイえず業歯そつ情道タネモ回 +着ぞあイい本要イ肉見線館そむふ。 + +死まこリば治9高詳かすぼク精報今ヒユエ水87学ニミナ急望とげがか芸会どげ原 +員カリミ信絶ホネワシ奪己あうは般裁午ハ招候森嘉ほ。決リぞ初定物るべ橋投レげ料 +代ひまわト員野トイるゅ表高んをご付検モ健棋ッよ情作ヘサラ本代ヘシタソ毎 +権びつずぱ続碁約ヤオマカ破見ヱ暮私フ住18逃見ゅは。変む記78実落ご破府ケ広千ンラ +供案ミ払処査ぽとそ向7窒特さ阪音隣ラウヨ稿分ひだ。 + +神セモル京森97問らた文客44盾粘3聴きッい権不サ雑種フワ治出えよ善重さ真56更 +教ヲマヱネ記内ラの出変げ温評マエトチ名需さラの。及済ニ場策カホ掲解ア養20済 +入にン惑提ク何一ぼおあた公図とフぴン任必レウム案雇止メ球化ヒ募座ヤシコ方列岸 +十たイぜ。料真ぎンぐ無基たをょか更写ちろッむ中聞んずぽ赤日リ当就ヱルハ行 +会リふイ夜学ウ面治ンク者京つりッ望築トみラつ易若計害阪倶りス。 + +名54徳リれ覧立モナフ息討三報ひそねで保朝ヨヒト表10外ずょ新戦固ド立経健けフふめ +前判むざぞて中然どレづ平卸呪娯なみ。稚ヨサラ通略クラソス欲寺日ちろうー持稿ば両 +原ヨハミ相定ラ投画ひ著64担ケノヲア堂造にほひう名天並ヱソマ能加ネ道厳 +殺びのリり。水づじ購東むづるめ売独れ書門ルナメラ応務ド写6読ラハヤエ実案造ヒス +経運ふで万歴剛もーっ著逆てろびひ法通ワニ改市キヤラ近教ルセ級督積利江ンねお。 + + +# Korean + +국가의 세입·세출의 결산, 국가 및 법률이 정한 단체의 회계검사와 행정기관 및 +공무원의 직무에 관한 감찰을 하기 위하여 대통령 소속하에 감사원을 둔다. +일반사면을 명하려면 국회의 동의를 얻어야 한다. 공공필요에 의한 재산권의 +수용·사용 또는 제한 및 그에 대한 보상은 법률로써 하되, 정당한 보상을 지급하여야 +한다. + +대통령은 국가의 안위에 관계되는 중대한 교전상태에 있어서 국가를 보위하기 위하여 +긴급한 조치가 필요하고 국회의 집회가 불가능한 때에 한하여 법률의 효력을 가지는 +명령을 발할 수 있다. 국가유공자·상이군경 및 전몰군경의 유가족은 법률이 정하는 +바에 의하여 우선적으로 근로의 기회를 부여받는다. + +대법관의 임기는 6년으로 하며, 법률이 정하는 바에 의하여 연임할 수 있다. 모든 +국민은 학문과 예술의 자유를 가진다. 제3항의 승인을 얻지 못한 때에는 그 처분 +또는 명령은 그때부터 효력을 상실한다. 이 경우 그 명령에 의하여 개정 또는 +폐지되었던 법률은 그 명령이 승인을 얻지 못한 때부터 당연히 효력을 회복한다. + +대통령은 국무회의의 의장이 되고, 국무총리는 부의장이 된다. 국무총리는 대통령을 +보좌하며, 행정에 관하여 대통령의 명을 받아 행정각부를 통할한다. 모든 국민은 +근로의 권리를 가진다. 국가는 사회적·경제적 방법으로 근로자의 고용의 증진과 +적정임금의 보장에 노력하여야 하며, 법률이 정하는 바에 의하여 최저임금제를 +시행하여야 한다. diff --git a/filters/vectors/wrap-cjk.in b/filters/vectors/wrap-cjk.in new file mode 100644 index 0000000..364376c --- /dev/null +++ b/filters/vectors/wrap-cjk.in @@ -0,0 +1,30 @@ +# Chinese + +涾一祄琌幄睟亍瑳褭冘,蚳乜枲挻媗楪乇酳冘仡。毰侘癿澺搥諨厊一釽埸貣侗夯昺丌,筥跐亍阽疪暔芫氿乇邳嵺苵殳刳。紬一炰郴啿蓱亍銍瑿毌,脧乜俉舲棬罧屮碞冇汃。殳珨攽祪陎齵僆裞扠尥畇妧矻杶芏瑗屮抰廑嫊。罣一洒剫鈂搣丌餈蹅圠,絇乜苭梇睌痯乇獃气仚。琇一峓趹絟痯丌慱懌夬,聇乇羍耛堙祽亍瑱冇圣。釽炖灱鴞飶橝邗一絘珴彧呥仩峛乇,嗂揤兀泲垛嗒侇汃亍沭鉽毘仈姈。舺一柼婐喑媐乜漮蕣丱,蚹兀玵窔棤跬丌蒔丮庂。 + +夬珓芶婤迠靃萰輈匢朳姷妡泆坨忻鈱乇沴瞁翜。丏茯炓笵苹靃瑒窣囡囡胘甹厒竻汦壾兀拊箤雸。夬栴刲烸垌鑫粲綍仵汔胍尪怓枟佁搹丌帔摓椴。惔一帡梜淼蒍乜嵼懁尐,崞乇虼軗鈚頍屮髧殳卌。厹迼岪孲茌瓥旓惷艸厊郕巠怓昃伻艀亍欥跿楪。挻一峊娹晪誄兀銆濏仂,軜屮昦捵棝幋屮搿尐尕。郴一羑牾睄趓屮蒝徻亓,剭丌怷祩硤酯丌鉺卬帄。 + +夃垸弢莗枹蠷僉椴扥阣胇玔抪岟杍稗兀泂蓒媐。雂怮吇謔詵鋾扡一蛬焐浰妽尻狪亍,赨鈀丌欥赲蜎屇阞丌芛獑罘冇芧。庴一茇紬鈏溦乇漭錉冘,梪兀秖軜湱僉乜鄣殳氻。掾沝仵螛塣螇艼一觝衒脁屇宁庛乇,朠棔兀屇挍搷咂仚亍怭馺俍卬岠。冘舯枎翋怹鸂雎祹朾朻耎肒泀狖帊萿亍呯榡搧。絓芩朿褢楩瘼妅一揘崚桏厒屴俬乜,摁嵫乜怬娀廆姁氶乇抶蓗柲丮芼。袼一虴梮喵搨乜蜑廦丱,紨丌枲翊傔葑亍漊气圣。剫一耏烸蛪鈳亍膇鋻旡,酕丌柣啒晲裬乜緄仉屴。渃泆妅嶵酨叡玎一罥桷捊劼汃癹乇,煐厤亍泝籺萻咍仚兀矼墔柧尐抾。 + +偢一苲猞敪窣乜漊憴尐,眥兀屌笰湒楢兀槊巿氶。旡秝盰絅笁鼞楜雺虍阢垌犿昈姎抌壼乜矼褙蛖。厹茦皯唲釓鸂瘃暊阠扡咠忨妺沷佖閟丌肏睾睒。跁一茌掑褁摀亍輎閾巿,紻丌柜崌蛝雎乜慺厹夯。氪一罘粣湒僁兀嫛踼爿,脕亍羑翊羡犐兀戩勼扐。蛅一庢粣缾艅乜嶀篣仈,秶乜紃荾埵寖乇戩丮圣。仉罜怢庴氠齵巰輆刐庄宬岒盰矷灴葝丌昍綩銢。冇倎侚捥柼瓛貄媱厊艼柎卣弣芠玗楈乜抭蜺嗊。 + +# Japanese + +応ルモユム格分ぎっ中側な例披ロオソリ盛申みこへ教更反トホ写3録47男ソ朝開ク中属ノ速法む米指ふさりこ考震フ明訟ばだ著敢渓窮胡よ。予ムヒト選技32浴ぜが子円もみ歳旅せてうけ実石ミサアト未9首放レミ募権大ん同市体づイえず業歯そつ情道タネモ回着ぞあイい本要イ肉見線館そむふ。 + +死まこリば治9高詳かすぼク精報今ヒユエ水87学ニミナ急望とげがか芸会どげ原員カリミ信絶ホネワシ奪己あうは般裁午ハ招候森嘉ほ。決リぞ初定物るべ橋投レげ料代ひまわト員野トイるゅ表高んをご付検モ健棋ッよ情作ヘサラ本代ヘシタソ毎権びつずぱ続碁約ヤオマカ破見ヱ暮私フ住18逃見ゅは。変む記78実落ご破府ケ広千ンラ供案ミ払処査ぽとそ向7窒特さ阪音隣ラウヨ稿分ひだ。 + +神セモル京森97問らた文客44盾粘3聴きッい権不サ雑種フワ治出えよ善重さ真56更教ヲマヱネ記内ラの出変げ温評マエトチ名需さラの。及済ニ場策カホ掲解ア養20済入にン惑提ク何一ぼおあた公図とフぴン任必レウム案雇止メ球化ヒ募座ヤシコ方列岸十たイぜ。料真ぎンぐ無基たをょか更写ちろッむ中聞んずぽ赤日リ当就ヱルハ行会リふイ夜学ウ面治ンク者京つりッ望築トみラつ易若計害阪倶りス。 + +名54徳リれ覧立モナフ息討三報ひそねで保朝ヨヒト表10外ずょ新戦固ド立経健けフふめ前判むざぞて中然どレづ平卸呪娯なみ。稚ヨサラ通略クラソス欲寺日ちろうー持稿ば両原ヨハミ相定ラ投画ひ著64担ケノヲア堂造にほひう名天並ヱソマ能加ネ道厳殺びのリり。水づじ購東むづるめ売独れ書門ルナメラ応務ド写6読ラハヤエ実案造ヒス経運ふで万歴剛もーっ著逆てろびひ法通ワニ改市キヤラ近教ルセ級督積利江ンねお。 + + +# Korean + +국가의 세입·세출의 결산, 국가 및 법률이 정한 단체의 회계검사와 행정기관 및 공무원의 직무에 관한 감찰을 하기 위하여 대통령 소속하에 감사원을 둔다. 일반사면을 명하려면 국회의 동의를 얻어야 한다. 공공필요에 의한 재산권의 수용·사용 또는 제한 및 그에 대한 보상은 법률로써 하되, 정당한 보상을 지급하여야 한다. + +대통령은 국가의 안위에 관계되는 중대한 교전상태에 있어서 국가를 보위하기 위하여 긴급한 조치가 필요하고 국회의 집회가 불가능한 때에 한하여 법률의 효력을 가지는 명령을 발할 수 있다. 국가유공자·상이군경 및 전몰군경의 유가족은 법률이 정하는 바에 의하여 우선적으로 근로의 기회를 부여받는다. + +대법관의 임기는 6년으로 하며, 법률이 정하는 바에 의하여 연임할 수 있다. 모든 국민은 학문과 예술의 자유를 가진다. 제3항의 승인을 얻지 못한 때에는 그 처분 또는 명령은 그때부터 효력을 상실한다. 이 경우 그 명령에 의하여 개정 또는 폐지되었던 법률은 그 명령이 승인을 얻지 못한 때부터 당연히 효력을 회복한다. + +대통령은 국무회의의 의장이 되고, 국무총리는 부의장이 된다. 국무총리는 대통령을 보좌하며, 행정에 관하여 대통령의 명을 받아 행정각부를 통할한다. 모든 국민은 근로의 권리를 가진다. 국가는 사회적·경제적 방법으로 근로자의 고용의 증진과 적정임금의 보장에 노력하여야 하며, 법률이 정하는 바에 의하여 최저임금제를 시행하여야 한다. diff --git a/filters/vectors/wrap-flowed.expected b/filters/vectors/wrap-flowed.expected new file mode 100644 index 0000000..358b810 --- /dev/null +++ b/filters/vectors/wrap-flowed.expected @@ -0,0 +1,94 @@ +Lorem ipsum dolor sit amet, vel id velit nonumy percipit, sed mutat partiendo +imperdiet ad, ad tritani deleniti duo? Vis id dicit inermis accumsan, ut +pertinax deterruisset his, at his quis appareat urbanitas. Pro falli invidunt +detraxit ex, vim cu graeci oblique contentiones, ea tale etiam aliquip eos. No +cum assum impetus verterem, tota feugait corpora ut vis. Sint harum eam cu, +magna doming quidam te est? Usu magna nihil antiopam et, eu latine ponderum +evertitur eos? At lucilius aliquando intellegebat mea, vis eros pertinax +similique ut. An mei deleniti + +forensibus. Iusto feugait maiestatis at nec, an est causae quaestio. Est ut +amet veritus, sea in tempor noluisse salutandi. Vis an omnis propriae, vix et +graece virtute, an integre sententiae his. Molestie tacimates id per, ex eum +etiam cetero? His cu altera constituam, eos ea inani dicant nonumy. Iudico +bonorum dissentiet eu quo. Usu ne purto essent qualisque, has alii soluta +adipisci ad, et fugit aeque omnesque mel. Fastidii facilisi inciderint usu cu, +ne iusto deterruisset his. Diceret expetendis reprimique id est, cu sit diam +tation accumsan, no per modus malorum. Pri scripta insolens sapientem an, +omnesque assueverit sea ea, eam paulo nemore argumentum te. Nam id atqui +incorrupte, cu eos quodsi ceteros. + +Vim ad sonet sadipscing, paulo epicuri mea ne. Mel an meliore denique omittam, +vidit insolens splendide no pri. Novum cetero quo ea, te his odio aperiri +hendrerit, at sonet mediocrem eam. Ea eam autem oporteat, est ut mucius +vituperatoribus. Soleat evertitur pri an? Cu vidit labore menandri nam, sit ex +paulo apeirian euripidis. Eos elit nominavi fabellas cu, has ne laoreet +torquatos! Accusam adolescens duo ut, legere periculis in qui, nibh euismod +epicurei vim id. Posse consul philosophia vis cu, ea brute delectus eum! +Debitis conclusionemque sed te, eam graeco equidem commune at. Eu duo laudem +animal, fugit scripserit ne eos. Ius delicata referrentur at? Vim id vidit +feugiat comprehensam? At error expetendis vel, vim habeo perfecto complectitur +ex. Ex vim possit persecuti! Unum referrentur instructior cu eum, alia legendos +incorrupte cu per, quo elitr veritus nominavi eu! + +> > Mei malis choro dolores ne, eu erat vocibus denique has? Sed ea ullum +> > deleniti, nam tritani aliquando complectitur ea! Mea no facer tempor +> > alienum, fugit laoreet gloriatur eos cu. Ne mollis ceteros eum, eos an +> > quodsi corpora. Modo ferri porro eu vis, putent dictas eloquentiam eos id. +> > Ei eum odio possim definiebas, eum noster doctus ea. Exerci nemore +> > gloriatur et vim! In torquatos sadipscing ius, ius ut debet dicant +> > senserit. Eu mel omnes ubique, et consul hendrerit constituam mea, et has +> > dicta integre? Cu eam suas libris? Nisl autem facilis duo ea, meis latine +> > intellegam quo an. Zril nonumes officiis te sed, pri harum luptatum +> > disputationi ex. Ei vel illum tantas constituam, per stet oratio corpora +> > ei, cum tale natum illud in! Has ei ponderum posidonium, mundi feugiat +> > ponderum at usu, ut vim dictas principes. +> > +> +> Tantas fuisset adversarium eos ei, quas dolorum albucius sea ne. Mea eu +> pertinax consequat, eu mei nostrud facilisis, ut denique sadipscing sed? Eu +> fugit elitr pericula per, nemore disputando his et. Ei stet putent +> instructior eam, id civibus similique vim, est suas postea audire te! Error +> cetero et sed. Eu quis animal pertinax mei, an nec omittam hendrerit? Ut +> melius utroque laboramus qui? + +Lorem ipsum dolor sit amet, no choro invidunt mel, an blandit eligendi +maluisset eam. Ne eam splendide omittantur. Eu vix ferri appareat sententiae, +eum falli menandri ne. Malis bonorum ius eu, utinam honestatis vix at, dicta +delicatissimi eu mei. Ponderum quaerendum efficiantur pro ex, cum an tota +nonumy efficiantur. + +- Ea insolens quaerendum mea. An pro viris quidam liberavisse, lorem facer + erroribus ad mel. At sea graece concludaturque, duo in impedit accumsan + consequat. Cu pri dolorum vituperata, et vis aliquando complectitur, elitr + constituam ius no. + +- No detraxit ocurreret sed. Mundi omnes solet qui no, nam ex viderer + constituto. Et quo scripta aliquam gloriatur. At audiam dolorem ius, malis + omnes sensibus sit ut, eum quod euripidis ad. At nec verear senserit, + diceret honestatis et vel, quis conceptam et nec. Eam probo option ea. + +- Ius summo dolore te, in quo choro tritani atomorum? Esse putant nec te? + Consul iuvaret debitis vix ei. Sit dicta quando legere no, quas novum + adolescens at his, cu est mutat equidem. Ea mea tation bonorum, mel + complectitur deterruisset id. Vim ut dicunt tamquam, et his illud invenire. + +- Mei at quas ceteros tibique? Prompta ceteros persequeris usu ut, quo + appareat indoctum id, choro quidam iisque mea eu. Ea vide nonumy ceteros + qui, vix no reque dolores necessitatibus, id fugit libris facilisis sit! Ne + ocurreret honestatis nam. Ex reque maluisset per, eam facer ludus dicam ad. + Eam modus impedit intellegat ei! Per no libris utamur nostrum, cu sed mollis + accusamus. + +- Nam at nibh meis singulis, augue aliquando liberavisse eos ea. Audire + facilis perpetua mel ea. Vel te oportere indoctum volutpat, omittam eligendi + patrioque per an. Nec ut eirmod appetere deterruisset, dico insolens no pri, + solum liber vituperata at sit? Sea id ipsum fugit viris. + + + +- Sea bonorum instructior consectetuer in. Eum ex impedit volutpat. Pro an + stet definiebas necessitatibus. Aperiam facilis his et! Ei vim labitur + petentium, illum contentiones duo in? Id sed sale scriptorem. Sed et tollit + albucius? + diff --git a/filters/vectors/wrap-flowed.in b/filters/vectors/wrap-flowed.in new file mode 100644 index 0000000..8c4d78a --- /dev/null +++ b/filters/vectors/wrap-flowed.in @@ -0,0 +1,135 @@ +Lorem ipsum dolor sit amet, vel id velit nonumy +percipit, sed mutat partiendo imperdiet ad, ad +tritani deleniti duo? Vis id dicit inermis +accumsan, ut pertinax deterruisset his, at his +quis appareat urbanitas. Pro falli invidunt +detraxit ex, vim cu graeci oblique contentiones, +ea tale etiam aliquip eos. No cum assum impetus +verterem, tota feugait corpora ut vis. Sint harum +eam cu, magna doming quidam te est? Usu magna +nihil antiopam et, eu latine ponderum evertitur +eos? At lucilius aliquando intellegebat mea, vis +eros pertinax similique ut. An mei deleniti + +forensibus. Iusto feugait maiestatis at nec, an +est causae quaestio. Est ut amet veritus, sea in +tempor noluisse salutandi. Vis an omnis propriae, +vix et graece virtute, an integre sententiae his. +Molestie tacimates id per, ex eum etiam cetero? +His cu altera constituam, eos ea inani dicant +nonumy. Iudico bonorum dissentiet eu quo. Usu ne +purto essent qualisque, has alii soluta adipisci +ad, et fugit aeque omnesque mel. Fastidii facilisi +inciderint usu cu, ne iusto deterruisset his. +Diceret expetendis reprimique id est, cu sit diam +tation accumsan, no per modus malorum. Pri scripta +insolens sapientem an, omnesque assueverit sea ea, +eam paulo nemore argumentum te. Nam id atqui +incorrupte, cu eos quodsi ceteros. + +Vim ad sonet sadipscing, paulo epicuri mea ne. Mel +an meliore denique omittam, vidit insolens +splendide no pri. Novum cetero quo ea, te his odio +aperiri hendrerit, at sonet mediocrem eam. Ea eam +autem oporteat, est ut mucius vituperatoribus. +Soleat evertitur pri an? Cu vidit labore menandri +nam, sit ex paulo apeirian euripidis. Eos elit +nominavi fabellas cu, has ne laoreet torquatos! +Accusam adolescens duo ut, legere periculis in +qui, nibh euismod epicurei vim id. Posse consul +philosophia vis cu, ea brute delectus eum! Debitis +conclusionemque sed te, eam graeco equidem commune +at. Eu duo laudem animal, fugit scripserit ne eos. +Ius delicata referrentur at? Vim id vidit feugiat +comprehensam? At error expetendis vel, vim habeo +perfecto complectitur ex. Ex vim possit persecuti! +Unum referrentur instructior cu eum, alia legendos +incorrupte cu per, quo elitr veritus nominavi eu! + +> > Mei malis choro dolores ne, eu erat vocibus +> > denique has? Sed ea ullum deleniti, nam tritani +> > aliquando complectitur ea! Mea no facer tempor +> > alienum, fugit laoreet gloriatur eos cu. Ne mollis +> > ceteros eum, eos an quodsi corpora. Modo ferri +> > porro eu vis, putent dictas eloquentiam eos id. Ei +> > eum odio possim definiebas, eum noster doctus ea. +> > Exerci nemore gloriatur et vim! In torquatos +> > sadipscing ius, ius ut debet dicant senserit. Eu +> > mel omnes ubique, et consul hendrerit constituam +> > mea, et has dicta integre? Cu eam suas libris? +> > Nisl autem facilis duo ea, meis latine intellegam +> > quo an. Zril nonumes officiis te sed, pri harum +> > luptatum disputationi ex. Ei vel illum tantas +> > constituam, per stet oratio corpora ei, cum tale +> > natum illud in! Has ei ponderum posidonium, mundi +> > feugiat ponderum at usu, ut vim dictas principes. +> > +> +> Tantas fuisset adversarium eos ei, quas dolorum +> albucius sea ne. Mea eu pertinax consequat, eu mei +> nostrud facilisis, ut denique sadipscing sed? Eu +> fugit elitr pericula per, nemore disputando his +> et. Ei stet putent instructior eam, id civibus +> similique vim, est suas postea audire te! Error +> cetero et sed. Eu quis animal pertinax mei, an nec +> omittam hendrerit? Ut melius utroque laboramus +> qui? + +Lorem ipsum dolor sit amet, no choro invidunt mel, +an blandit eligendi maluisset eam. Ne eam +splendide omittantur. Eu vix ferri appareat +sententiae, eum falli menandri ne. Malis bonorum +ius eu, utinam honestatis vix at, dicta +delicatissimi eu mei. Ponderum quaerendum +efficiantur pro ex, cum an tota nonumy +efficiantur. + +- Ea insolens quaerendum mea. An pro viris quidam + liberavisse, lorem facer erroribus ad mel. At + sea graece concludaturque, duo in impedit + accumsan consequat. Cu pri dolorum vituperata, + et vis aliquando complectitur, elitr constituam + ius no. + +- No detraxit ocurreret sed. Mundi omnes solet + qui no, nam ex viderer constituto. Et quo + scripta aliquam gloriatur. At audiam dolorem + ius, malis omnes sensibus sit ut, eum quod + euripidis ad. At nec verear senserit, diceret + honestatis et vel, quis conceptam et nec. Eam + probo option ea. + +- Ius summo dolore te, in quo choro tritani + atomorum? Esse putant nec te? Consul iuvaret + debitis vix ei. Sit dicta quando legere no, + quas novum adolescens at his, cu est mutat + equidem. Ea mea tation bonorum, mel + complectitur deterruisset id. Vim ut dicunt + tamquam, et his illud invenire. + +- Mei at quas ceteros tibique? Prompta ceteros + persequeris usu ut, quo appareat indoctum id, + choro quidam iisque mea eu. Ea vide nonumy + ceteros qui, vix no reque dolores + necessitatibus, id fugit libris facilisis sit! + Ne ocurreret honestatis nam. Ex reque maluisset + per, eam facer ludus dicam ad. Eam modus + impedit intellegat ei! Per no libris utamur + nostrum, cu sed mollis accusamus. + +- Nam at nibh meis singulis, augue aliquando + liberavisse eos ea. Audire facilis perpetua mel + ea. Vel te oportere indoctum volutpat, omittam + eligendi patrioque per an. Nec ut eirmod + appetere deterruisset, dico insolens no pri, + solum liber vituperata at sit? Sea id ipsum + fugit viris. + + + +- Sea bonorum instructior consectetuer in. Eum ex + impedit volutpat. Pro an stet definiebas + necessitatibus. Aperiam facilis his et! Ei vim + labitur petentium, illum contentiones duo in? + Id sed sale scriptorem. Sed et tollit albucius? + diff --git a/filters/vectors/wrap-lists.expected b/filters/vectors/wrap-lists.expected new file mode 100644 index 0000000..50f34ae --- /dev/null +++ b/filters/vectors/wrap-lists.expected @@ -0,0 +1,47 @@ +- Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi purus arcu, + facilisis sit amet augue ac, scelerisque interdum enim. Interdum et malesuada + fames ac ante ipsum primis in faucibus. + + * Pellentesque vitae maximus odio. Vestibulum ante ipsum primis in faucibus + orci luctus et ultrices posuere cubilia curae; + + * Vestibulum dui tortor, fermentum vitae elit nec, vulputate malesuada + nisi. Aliquam blandit non ipsum quis dignissim. + +- Vivamus rhoncus augue magna, a maximus augue ultrices imperdiet. Vivamus nec + nisl dolor. Vestibulum lacinia dolor diam. + +a) Phasellus et consequat nisi. In laoreet sodales velit, vitae porttitor + ligula varius et. Donec non nibh mi. Fusce nec blandit lectus. Morbi id + dolor eu arcu tempus fermentum in quis lectus. +b) Nullam tempus orci vitae est dapibus, sit amet consectetur sapien dictum. + Aliquam accumsan dolor arcu, in condimentum purus dignissim nec. +c) Donec sed enim sodales, aliquam elit vel, feugiat sem. Donec sed fringilla + risus, eget gravida lectus. Aenean vitae vulputate turpis. + +1. Nullam maximus ligula eget leo porttitor, sed viverra urna aliquam. Etiam + mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. + +2. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, + ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. + Etiam venenatis id est et ornare. + +i. Pellentesque vitae maximus odio. Vestibulum ante ipsum primis in + faucibus orci luctus et ultrices posuere cubilia curae; + +ii. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi purus + arcu, facilisis sit amet augue ac, scelerisque interdum enim. Interdum + et malesuada fames ac ante ipsum primis in faucibus. + +iii. Vivamus rhoncus augue magna, a maximus augue ultrices imperdiet. Vivamus + nec nisl dolor. Vestibulum lacinia dolor diam. + +IV. Nullam tempus orci vitae est dapibus, sit amet consectetur sapien + dictum. Aliquam accumsan dolor arcu, in condimentum purus dignissim nec. + +V. Phasellus et consequat nisi. In laoreet sodales velit, vitae porttitor + ligula varius et. Donec non nibh mi. Fusce nec blandit lectus. Morbi id + dolor eu arcu tempus fermentum in quis lectus. + +VI. Donec sed enim sodales, aliquam elit vel, feugiat sem. Donec sed + fringilla risus, eget gravida lectus. Aenean vitae vulputate turpis. diff --git a/filters/vectors/wrap-lists.in b/filters/vectors/wrap-lists.in new file mode 100644 index 0000000..38a15c3 --- /dev/null +++ b/filters/vectors/wrap-lists.in @@ -0,0 +1,27 @@ +- Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi purus arcu, facilisis sit amet augue ac, scelerisque interdum enim. Interdum et malesuada fames ac ante ipsum primis in faucibus. + + * Pellentesque vitae maximus odio. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; + + * Vestibulum dui tortor, fermentum vitae elit nec, vulputate malesuada nisi. Aliquam blandit non ipsum quis dignissim. + +- Vivamus rhoncus augue magna, a maximus augue ultrices imperdiet. Vivamus nec nisl dolor. Vestibulum lacinia dolor diam. + +a) Phasellus et consequat nisi. In laoreet sodales velit, vitae porttitor ligula varius et. Donec non nibh mi. Fusce nec blandit lectus. Morbi id dolor eu arcu tempus fermentum in quis lectus. +b) Nullam tempus orci vitae est dapibus, sit amet consectetur sapien dictum. Aliquam accumsan dolor arcu, in condimentum purus dignissim nec. +c) Donec sed enim sodales, aliquam elit vel, feugiat sem. Donec sed fringilla risus, eget gravida lectus. Aenean vitae vulputate turpis. + +1. Nullam maximus ligula eget leo porttitor, sed viverra urna aliquam. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. + +2. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. + +i. Pellentesque vitae maximus odio. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; + +ii. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi purus arcu, facilisis sit amet augue ac, scelerisque interdum enim. Interdum et malesuada fames ac ante ipsum primis in faucibus. + +iii. Vivamus rhoncus augue magna, a maximus augue ultrices imperdiet. Vivamus nec nisl dolor. Vestibulum lacinia dolor diam. + +IV. Nullam tempus orci vitae est dapibus, sit amet consectetur sapien dictum. Aliquam accumsan dolor arcu, in condimentum purus dignissim nec. + +V. Phasellus et consequat nisi. In laoreet sodales velit, vitae porttitor ligula varius et. Donec non nibh mi. Fusce nec blandit lectus. Morbi id dolor eu arcu tempus fermentum in quis lectus. + +VI. Donec sed enim sodales, aliquam elit vel, feugiat sem. Donec sed fringilla risus, eget gravida lectus. Aenean vitae vulputate turpis. diff --git a/filters/vectors/wrap-quotes.expected b/filters/vectors/wrap-quotes.expected new file mode 100644 index 0000000..8367102 --- /dev/null +++ b/filters/vectors/wrap-quotes.expected @@ -0,0 +1,388 @@ +Pellentesque vitae maximus odio. Vestibulum ante ipsum primis in faucibus orci +luctus et ultrices posuere cubilia curae; Vestibulum dui tortor, fermentum +vitae elit nec, vulputate malesuada nisi. + +> > Aliquam blandit non ipsum quis dignissim. Vivamus rhoncus augue magna, a +> > maximus augue ultrices imperdiet. Vivamus nec nisl dolor. +> Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi purus arcu, +> facilisis sit amet augue ac, scelerisque interdum enim. Interdum et malesuada +> fames ac ante ipsum primis in faucibus. + +Vestibulum lacinia dolor diam. Phasellus et consequat nisi. In laoreet sodales +velit, vitae porttitor ligula varius et. Donec non nibh mi. + +>>> Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, +>>> ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. +>> Fusce nec blandit lectus. Morbi id dolor eu arcu tempus fermentum in quis +>> lectus. Nullam tempus orci vitae est dapibus, sit amet consectetur sapien +>> dictum. Aliquam accumsan dolor arcu, in condimentum purus dignissim nec. +> Nullam maximus ligula eget leo porttitor, sed viverra urna aliquam. Donec sed +> enim sodales, aliquam elit vel, feugiat sem. Donec sed fringilla risus, eget +> gravida lectus. Aenean vitae vulputate turpis. + +Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer +eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices +porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id +est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis +metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis +sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. +Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, +maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas +non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac +consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis +augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat +fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus +condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi +mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend +ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor +urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et +ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. +Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, +ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam +venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, +maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas +non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac +consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis +augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat +fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus +condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi +mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend +ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor +urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et +ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. +Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, +ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam +venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, +maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas +non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac +consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis +augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat +fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus +condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi +mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend +ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor +urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et +ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. +Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, +ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam +venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, +maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas +non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac +consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis +augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat +fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus +condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi +mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend +ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor +urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et +ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. +Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, +ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam +venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, +maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas +non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac +consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis +augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat +fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus +condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi +mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend +ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor +urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et +ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. +Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, +ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam +venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, +maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas +non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac +consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis +augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat +fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus +condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi +mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend +ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor +urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et +ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. +Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, +ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam +venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, +maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas +non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac +consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis +augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat +fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus +condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi +mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend +ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor +urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et +ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. +Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, +ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam +venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, +maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas +non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac +consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis +augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat +fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus +condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi +mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend +ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor +urna. Phasellus condimentum a elit ac consectetur. Etiam ve +nenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus +lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non +mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac +consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis +augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat +fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus +condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi +mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend +ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor +urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et +ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. +Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, +ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam +venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, +maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas +non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac +consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis +augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat +fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus +condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi +mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend +ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor +urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et +ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. +Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, +ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam +venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, +maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas +non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac +consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis +augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat +fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus +condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi +mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend +ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor +urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et +ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. +Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, +ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam +venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, +maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas +non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac +consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis +augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat +fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus +condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi +mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend +ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor +urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et +ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. +Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, +ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam +venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, +maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas +non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac +consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis +augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat +fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus +condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi +mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend +ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor +urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et +ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. +Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, +ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam +venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, +maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas +non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac +consectetur. Etiam venenatis id est et ornare. + +>> - Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. +>> Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, +>> ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. +>> Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue +>> vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat +>> fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus +>> condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam +>> mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer +>> eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, +>> ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. +>> Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue +>> vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat +>> fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus +>> condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam +>> mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer +>> eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, +>> ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. +>> Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue +>> vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat +>> fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus +>> condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam +>> mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer +>> eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, +>> ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. +>> Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue +>> vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat +>> fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus +>> condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam +>> mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer +>> eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, +>> ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. +>> Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue +>> vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat +>> fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus +>> condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam +>> mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer +>> eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, +>> ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. +>> Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue +>> vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat +>> fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus +>> condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam +>> mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer +>> eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, +>> ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. +>> Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue +>> vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat +>> fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus +>> condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam +>> mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer +>> eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, +>> ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. +>> Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue +>> vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat +>> fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus +>> condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam +>> mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer +>> eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, +>> ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. +>> Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue +>> vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat +>> fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus +>> condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam +>> mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer +>> eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, +>> ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. +>> Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue +>> vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat +>> fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus +>> condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam +>> mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer +>> eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, +>> ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. +>> Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue +>> vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat +>> fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus +>> condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam +>> mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer +>> eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, +>> ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. +>> Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue +>> vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat +>> fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus +>> condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam +>> mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer +>> eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, +>> ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. +>> Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue +>> vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat +>> fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus +>> condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam +>> mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer +>> eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, +>> ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. +>> Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue +>> vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat +>> fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus +>> condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam +>> mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer +>> eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, +>> ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. +>> Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue +>> vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat +>> fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus +>> condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam +>> mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer +>> eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, +>> ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. +>> Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue +>> vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat +>> fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus +>> condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam +>> mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer +>> eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, +>> ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Eti +am venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, +maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas +non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac +consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis +augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat +fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus +condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi +mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend +ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor +urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et +ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. +Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, +ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam +venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, +maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas +non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac +consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis +augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat +fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus +condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi +mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend +ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor +urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et +ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. +Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, +ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam +venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, +maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas +non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac +consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis +augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat +fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus +condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi +mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend +ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor +urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et +ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. +Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, +ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam +venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, +maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas +non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac +consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis +augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat +fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus +condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi +mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend +ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor +urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et +ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. +Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, +ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam +venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, +maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas +non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac +consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis +augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat +fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus +condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi +mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend +ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor +urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et +ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. +Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, +ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam +venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, +maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas +non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac +consectetur. Etiam venenatis id est et ornare. + +>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> tavu + +-- +salut diff --git a/filters/vectors/wrap-quotes.in b/filters/vectors/wrap-quotes.in new file mode 100644 index 0000000..ff1bef2 --- /dev/null +++ b/filters/vectors/wrap-quotes.in @@ -0,0 +1,19 @@ +Pellentesque vitae maximus odio. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Vestibulum dui tortor, fermentum vitae elit nec, vulputate malesuada nisi. + +> > Aliquam blandit non ipsum quis dignissim. Vivamus rhoncus augue magna, a maximus augue ultrices imperdiet. Vivamus nec nisl dolor. +> Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi purus arcu, facilisis sit amet augue ac, scelerisque interdum enim. Interdum et malesuada fames ac ante ipsum primis in faucibus. + +Vestibulum lacinia dolor diam. Phasellus et consequat nisi. In laoreet sodales velit, vitae porttitor ligula varius et. Donec non nibh mi. + +>>> Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. +>> Fusce nec blandit lectus. Morbi id dolor eu arcu tempus fermentum in quis lectus. Nullam tempus orci vitae est dapibus, sit amet consectetur sapien dictum. Aliquam accumsan dolor arcu, in condimentum purus dignissim nec. +> Nullam maximus ligula eget leo porttitor, sed viverra urna aliquam. Donec sed enim sodales, aliquam elit vel, feugiat sem. Donec sed fringilla risus, eget gravida lectus. Aenean vitae vulputate turpis. + +Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. + +>> - Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. + +>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> tavu + +-- +salut diff --git a/filters/vectors/wrap-simple.expected b/filters/vectors/wrap-simple.expected new file mode 100644 index 0000000..af68ad5 --- /dev/null +++ b/filters/vectors/wrap-simple.expected @@ -0,0 +1,163 @@ +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi purus arcu, +facilisis sit amet augue ac, scelerisque interdum enim. Interdum et malesuada +fames ac ante ipsum primis in faucibus. Pellentesque vitae maximus odio. +Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere +cubilia curae; Vestibulum dui tortor, fermentum vitae elit nec, vulputate +malesuada nisi. Aliquam blandit non ipsum quis dignissim. + + Vivamus rhoncus augue magna, a maximus augue ultrices imperdiet. Vivamus nec + nisl dolor. Vestibulum lacinia dolor diam. Phasellus et consequat nisi. In + laoreet sodales velit, vitae porttitor ligula varius et. Donec non nibh mi. + Fusce nec blandit lectus. Morbi id dolor eu arcu tempus fermentum in quis + lectus. Nullam tempus orci vitae est dapibus, sit amet consectetur sapien + dictum. Aliquam accumsan dolor arcu, in condimentum purus dignissim nec. + +Donec sed enim sodales, aliquam elit vel, feugiat sem. Donec sed fringilla +risus, eget gravida lectus. Aenean vitae vulputate turpis. Nullam maximus +ligula eget leo porttitor, sed viverra urna aliquam. Etiam mi mauris, lacinia +iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem +feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. +Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. + +Lorem ipsum dolor sit amet, partem vituperatoribus ad usu, pro te quas virtute +deserunt! Et quando iriure sit, ius an scripta blandit deseruisse. Ei vix justo +vituperatoribus, te aliquid salutatus mel, his sanctus detracto invenire ex. +Eos ei quando facete? Nam etiam elitr laboramus in. At erroribus corrumpit +intellegat nec, vel te mundi atomorum molestiae! No pro alii senserit +comprehensam, sit melius suscipit ea? Audiam officiis expetendis cum ad, ea +ludus urbanitas vel, ad molestie detraxit necessitatibus mei. Quo reque +propriae atomorum ne. Partem detraxit vituperatoribus qui eu, mel ad fugit +temporibus, usu eu graecis singulis instructior. Ne soluta semper electram duo, +pro paulo viris eu, ut tota timeam reprehendunt nec. No zril euismod maluisset +per, quod aperiam placerat ad nec! Ei eum clita epicurei, te his expetenda +intellegam, an simul pericula est. Eum suavitate efficiantur te, vidit +ocurreret has ne. Quodsi omnium albucius quo id, dicat homero partem no sed? At +est novum denique, qui in mundi atomorum, cu dicunt expetendis efficiantur pro. +Amet tractatos repudiandae et vis, everti omnium voluptaria at vel. Mel modus +electram et, ea mutat denique elaboraret vim. Per ei quas doming molestiae. +Tation nonumes et usu, meis dolorem in eos. Ex reque doming vocibus ius, sit id +vitae veritus. At sale magna quo. Et sumo tota pri? Quo ut novum iisque. Probo +convenire et eum, te everti vocibus mel? Quas soleat has in, ex hinc indoctum +vim! Cu homero sanctus legimus his. Mei enim phaedrum cotidieque te? In his +veri conceptam. Vix et stet tractatos erroribus, ea quas fabellas qui. Id +tacimates referrentur necessitatibus pri, sit summo congue ea. Quo hinc +appetere postulant id, sit eu sint integre discere, pri alienum officiis te! +Facete numquam ius in, mei no delectus convenire! Assum tempor in vis, te +ridens assentior comprehensam quo? Illud aliquam est te, laudem libris iuvaret +eos eu. Elitr quaerendum an his, vel quod purto etiam te. Eu has patrioque +abhorreant. Sit minim scripserit at. Latine bonorum explicari an sed, mel cu +ridens saperet iudicabit. An eum audiam corpora. Recusabo postulant vix ex, mea +ad partem ornatus. Quando quaestio percipitur pri at, nostrud numquam +repudiandae et nam. Erroribus patrioque rationibus vix ut, autem veniam semper +eos id? Legimus nominati ea duo, ut accumsan copiosae perfecto usu, duo an quem +minimum blandit! Te vix iudico sensibus partiendo, vero ocurreret in eos! Vis +posse dicunt et? Sed mutat maiestatis at, at stet suavitate duo? His in +fastidii legendos molestiae? Sea et deserunt consequat! Ad viris graecis +invidunt pri, ea nullam quodsi consectetuer sea, mea ex zril phaedrum mandamus. +Pro esse duis ad. At vel albucius tractatos, amet error erroribus ad vis? His +putant nominavi indoctum ne, no munere aperiri consequat sed, id nec facilisis +maiestatis signiferumque. His quot modus melius an. Mea ex tale feugait +splendide, idque consul euismod eam ei, oblique interesset ea eam. Soleat +liberavisse vis ea. Cu mel quis virtute, nec discere civibus mentitum ex, ei +recusabo invenire cum. Ut eam cetero oporteat, unum option vituperata cu usu. +Et eum everti doctus. Eu mazim veritus reprehendunt qui? Modo doctus +neglegentur his in. Vis ea modus quidam constituto, tamquam integre moderatius +quo eu, no duo amet etiam conclusionemque. Sed ea nusquam eligendi, no erant +labores volumus sit. Eu cum virtute fuisset inimicus, dolore assentior cu his. +Eius gloriatur interesset eu usu. Vis magna inciderint cu, mea idque voluptatum +reprehendunt te? Vivendo epicuri eu eum, adhuc complectitur nam no. Usu eripuit +adolescens eu, in eum democritum efficiendi! Ea nullam persius sed, case +aliquid scribentur ut est. Ad minim voluptatum eam, cu alterum convenire vis, +cum no agam dicunt vituperatoribus? At ius justo dissentiet eloquentiam! Vim ne +oblique nostrum gloriatur, qui cu appetere petentium repudiandae? Ne qui veniam +quodsi constituto, eum ei vocent referrentur vituperatoribus, melius verterem +vituperatoribus vel an. Quo an partem antiopam. Legere postea maiestatis ea +vel, pri an modus nominati mediocrem. Diam elit officiis an eos, eos doming +malorum utroque te! Atqui nominavi forensibus cu mei, ipsum noluisse eu ius? +Commune noluisse volutpat sea te, nec utinam albucius appellantur an. Ex +petentium definitiones eos! Dico vero persius ex mel, sea malis ullum ex. +Lobortis dissentiunt pro an, eum putant senserit cu, eos unum luptatum volutpat +in. Iuvaret persecuti ex sit. Pertinacia consequuntur ut vel, nec saepe animal +imperdiet cu. Agam illud ut nec, ea vidisse appetere mandamus pro, ea verterem +pericula est. Sea ne dolores definiebas, pri et eirmod epicuri argumentum. +Nostro discere prodesset eam ea, debet offendit ut duo. Mea case iisque +disputando no? Nec consul mediocrem et. Sumo disputationi ex vim, te sit +inermis lucilius inimicus, eam eu ludus propriae cotidieque. Ea putant nominavi +pri! Senserit eloquentiam ei his. Est eu solum gubergren! Vix an omnis facer, +enim adhuc sonet id eum. Sea minim semper alterum id? Vel ut nominavi officiis, +mollis salutatus consulatu vis id. Porro lobortis has id? Assum everti viderer +nam ut? Nam pertinacia forensibus te, no cibo habemus ocurreret mei. Nibh +maluisset intellegebat est ei! Ad nec commodo oportere, ad mei congue maiorum? +Ne vix quem dicta, causae doctus feugiat mei no, commune argumentum his ei! +Eirmod fabulas voluptaria eos et! Ea eam quot elit, aliquip sententiae qui at. +Lorem ipsum dolor sit amet, partem vituperatoribus ad usu, pro te quas virtute +deserunt! Et quando iriure sit, ius an scripta blandit deseruisse. Ei vix justo +vituperatoribus, te aliquid salutatus mel, his sanctus detracto invenire ex. +Eos ei quando facete? Nam etiam elitr laboramus in. At erroribus corrumpit +intellegat nec, vel te mundi atomorum molestiae! No pro alii senserit +comprehensam, sit melius suscipit ea? Audiam officiis expetendis cum ad, ea +ludus urbanitas vel, ad molestie detraxit necessitatibus mei. Quo reque +propriae atomorum ne. Partem detraxit vituperatoribus qui eu, mel ad fugit +temporibus, usu eu graecis singulis instructior. Ne soluta semper electram duo, +pro paulo viris eu, ut tota timeam reprehendunt nec. No zril euismod maluisset +per, quod aperiam placerat ad nec! Ei eum clita epicurei, te his expetenda +intellegam, an simul pericula est. Eum suavitate efficiantur te, vidit +ocurreret has ne. Quodsi omnium albucius quo id, dicat homero partem no sed? At +est novum denique, qui in mundi atomorum, cu dicunt expetendis efficiantur pro. +Amet tractatos repudiandae et vis, everti omnium voluptaria at vel. Mel modus +electram et, ea mutat denique elaboraret vim. Per ei quas doming molestiae. +Tation nonumes et usu, meis dolorem in eos. Ex reque doming vocibus ius, sit id +vitae veritus. At sale magna quo. Et sumo tota pri? Quo ut novum iisque. Probo +convenire et eum, te everti vocibus mel? Quas soleat has in, ex hinc indoctum +vim! Cu homero sanctus legimus his. Mei enim phaedrum cotidieque te? In his +veri conceptam. Vix et stet tractatos erroribus, ea quas fabellas qui. Id +tacimates referrentur necessitatibus pri, sit summo congue ea. Quo hinc +appetere postulant id, sit eu sint integre discere, pri alienum officiis te! +Facete numquam ius in, mei no delectus convenire! Assum tempor in vis, te +ridens assentior comprehensam quo? Illud aliquam est te, laudem libris iuvaret +eos eu. Elitr quaerendum an his, vel quod purto etiam te. Eu has patrioque +abhorreant. Sit minim scripserit at. Latine bonorum explicari an sed, mel cu +ridens saperet iudicabit. An eum audiam corpora. Recusabo postulant vix ex, mea +ad partem ornatus. Quando quaestio percipitur pri at, nostrud numquam +repudiandae et nam. Erroribus patrioque rationibus vix ut, autem veniam semper +eos id? Legimus nominati ea duo, ut accumsan copiosae perfecto usu, duo an quem +minimum blandit! Te vix iudico sensibus partiendo, vero ocurreret in eos! Vis +posse dicunt et? Sed mutat maiestatis at, at stet suavitate duo? His in +fastidii legendos molestiae? Sea et deserunt consequat! Ad viris graecis +invidunt pri, ea nullam quodsi consectetuer sea, mea ex zril phaedrum mandamus. +Pro esse duis ad. At vel albucius tractatos, amet error erroribus ad vis? His +putant nominavi indoctum ne, no munere aperiri consequat sed, id nec facilisis +maiestatis signiferumque. His quot modus melius an. Mea ex tale feugait +splendide, idque consul euismod eam ei, oblique interesset ea eam. Soleat +liberavisse vis ea. Cu mel quis virtute, nec discere civibus mentitum ex, ei +recusabo invenire cum. Ut eam cetero oporteat, unum option vituperata cu usu. +Et eum everti doctus. Eu mazim veritus reprehendunt qui? Modo doctus +neglegentur his in. Vis ea modus quidam constituto, tamquam integre moderatius +quo eu, no duo amet etiam conclusionemque. Sed ea nusquam eligendi, no erant +labores volumus sit. Eu cum virtute fuisset inimicus, dolore assentior cu his. +Eius gloriatur interesset eu usu. Vis magna inciderint cu, mea idque voluptatum +reprehendunt te? Vivendo epicuri eu eum, adhuc complectitur nam no. Usu eripuit +adolescens eu, in eum democritum efficiendi! Ea nullam persius sed, case +aliquid scribentur ut est. Ad minim voluptatum eam, cu alterum convenire vis, +cum no agam dicunt vituperatoribus? At ius justo dissentiet eloquentiam! Vim ne +oblique nostrum gloriatur, qui cu appetere petentium repudiandae? Ne qui veniam +quodsi constituto, eum ei vocent referrentur vituperatoribus, melius verterem +vituperatoribus vel an. Quo an partem antiopam. Legere postea maiestatis ea +vel, pri an modus nominati mediocrem. Diam elit officiis an eos, eos doming +malorum utroque te! Atqui nominavi forensibus cu mei, ipsum noluisse eu ius? +Commune noluisse volutpat sea te, nec utinam albucius appellantur an. Ex +petentium definitiones eos! Dico vero persius ex mel, sea malis ullum ex. +Lobortis dissentiunt pro an, eum putant senserit cu, eos unum luptatum volutpat +in. Iuvaret persecuti ex sit. Pertinacia consequuntur ut vel, nec saepe animal +imperdiet cu. Agam illud ut nec, ea vidisse appetere mandamus pro, ea verterem +pericula est. Sea ne dolores definiebas, pri et eirmod epicuri argumentum. +Nostro discere prodesset eam ea, debet offendit ut duo. Mea case iisque +disputando no? Nec consul mediocrem et. Sumo disputationi ex vim, te sit +inermis lucilius inimicus, eam eu ludus propriae cotidieque. Ea putant nominavi +pri! Senserit eloquentiam ei his. Est eu solum gubergren! Vix an omnis facer, +enim adhuc sonet id eum. Sea minim semper alterum id? Vel ut nominavi officiis, +mollis salutatus consulatu vis id. Porro lobortis has id? Assum everti viderer +nam ut? Nam pertinacia forensibus te, no cibo habemus ocurreret mei. Nibh +maluisset intellegebat est ei! Ad nec commodo oportere, ad mei congue maiorum? +Ne vix quem dicta, causae doctus feugiat mei no, commune argumentum his ei! +Eirmod fabulas voluptaria eos et! Ea eam quot elit, aliquip sententiae qui at. diff --git a/filters/vectors/wrap-simple.in b/filters/vectors/wrap-simple.in new file mode 100644 index 0000000..454748e --- /dev/null +++ b/filters/vectors/wrap-simple.in @@ -0,0 +1,7 @@ +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi purus arcu, facilisis sit amet augue ac, scelerisque interdum enim. Interdum et malesuada fames ac ante ipsum primis in faucibus. Pellentesque vitae maximus odio. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Vestibulum dui tortor, fermentum vitae elit nec, vulputate malesuada nisi. Aliquam blandit non ipsum quis dignissim. + + Vivamus rhoncus augue magna, a maximus augue ultrices imperdiet. Vivamus nec nisl dolor. Vestibulum lacinia dolor diam. Phasellus et consequat nisi. In laoreet sodales velit, vitae porttitor ligula varius et. Donec non nibh mi. Fusce nec blandit lectus. Morbi id dolor eu arcu tempus fermentum in quis lectus. Nullam tempus orci vitae est dapibus, sit amet consectetur sapien dictum. Aliquam accumsan dolor arcu, in condimentum purus dignissim nec. + +Donec sed enim sodales, aliquam elit vel, feugiat sem. Donec sed fringilla risus, eget gravida lectus. Aenean vitae vulputate turpis. Nullam maximus ligula eget leo porttitor, sed viverra urna aliquam. Etiam mi mauris, lacinia iaculis augue vitae, maximus lobortis metus. Integer eleifend ante a sem feugiat fringilla. Maecenas non mattis sapien, ultrices porttitor urna. Phasellus condimentum a elit ac consectetur. Etiam venenatis id est et ornare. + +Lorem ipsum dolor sit amet, partem vituperatoribus ad usu, pro te quas virtute deserunt! Et quando iriure sit, ius an scripta blandit deseruisse. Ei vix justo vituperatoribus, te aliquid salutatus mel, his sanctus detracto invenire ex. Eos ei quando facete? Nam etiam elitr laboramus in. At erroribus corrumpit intellegat nec, vel te mundi atomorum molestiae! No pro alii senserit comprehensam, sit melius suscipit ea? Audiam officiis expetendis cum ad, ea ludus urbanitas vel, ad molestie detraxit necessitatibus mei. Quo reque propriae atomorum ne. Partem detraxit vituperatoribus qui eu, mel ad fugit temporibus, usu eu graecis singulis instructior. Ne soluta semper electram duo, pro paulo viris eu, ut tota timeam reprehendunt nec. No zril euismod maluisset per, quod aperiam placerat ad nec! Ei eum clita epicurei, te his expetenda intellegam, an simul pericula est. Eum suavitate efficiantur te, vidit ocurreret has ne. Quodsi omnium albucius quo id, dicat homero partem no sed? At est novum denique, qui in mundi atomorum, cu dicunt expetendis efficiantur pro. Amet tractatos repudiandae et vis, everti omnium voluptaria at vel. Mel modus electram et, ea mutat denique elaboraret vim. Per ei quas doming molestiae. Tation nonumes et usu, meis dolorem in eos. Ex reque doming vocibus ius, sit id vitae veritus. At sale magna quo. Et sumo tota pri? Quo ut novum iisque. Probo convenire et eum, te everti vocibus mel? Quas soleat has in, ex hinc indoctum vim! Cu homero sanctus legimus his. Mei enim phaedrum cotidieque te? In his veri conceptam. Vix et stet tractatos erroribus, ea quas fabellas qui. Id tacimates referrentur necessitatibus pri, sit summo congue ea. Quo hinc appetere postulant id, sit eu sint integre discere, pri alienum officiis te! Facete numquam ius in, mei no delectus convenire! Assum tempor in vis, te ridens assentior comprehensam quo? Illud aliquam est te, laudem libris iuvaret eos eu. Elitr quaerendum an his, vel quod purto etiam te. Eu has patrioque abhorreant. Sit minim scripserit at. Latine bonorum explicari an sed, mel cu ridens saperet iudicabit. An eum audiam corpora. Recusabo postulant vix ex, mea ad partem ornatus. Quando quaestio percipitur pri at, nostrud numquam repudiandae et nam. Erroribus patrioque rationibus vix ut, autem veniam semper eos id? Legimus nominati ea duo, ut accumsan copiosae perfecto usu, duo an quem minimum blandit! Te vix iudico sensibus partiendo, vero ocurreret in eos! Vis posse dicunt et? Sed mutat maiestatis at, at stet suavitate duo? His in fastidii legendos molestiae? Sea et deserunt consequat! Ad viris graecis invidunt pri, ea nullam quodsi consectetuer sea, mea ex zril phaedrum mandamus. Pro esse duis ad. At vel albucius tractatos, amet error erroribus ad vis? His putant nominavi indoctum ne, no munere aperiri consequat sed, id nec facilisis maiestatis signiferumque. His quot modus melius an. Mea ex tale feugait splendide, idque consul euismod eam ei, oblique interesset ea eam. Soleat liberavisse vis ea. Cu mel quis virtute, nec discere civibus mentitum ex, ei recusabo invenire cum. Ut eam cetero oporteat, unum option vituperata cu usu. Et eum everti doctus. Eu mazim veritus reprehendunt qui? Modo doctus neglegentur his in. Vis ea modus quidam constituto, tamquam integre moderatius quo eu, no duo amet etiam conclusionemque. Sed ea nusquam eligendi, no erant labores volumus sit. Eu cum virtute fuisset inimicus, dolore assentior cu his. Eius gloriatur interesset eu usu. Vis magna inciderint cu, mea idque voluptatum reprehendunt te? Vivendo epicuri eu eum, adhuc complectitur nam no. Usu eripuit adolescens eu, in eum democritum efficiendi! Ea nullam persius sed, case aliquid scribentur ut est. Ad minim voluptatum eam, cu alterum convenire vis, cum no agam dicunt vituperatoribus? At ius justo dissentiet eloquentiam! Vim ne oblique nostrum gloriatur, qui cu appetere petentium repudiandae? Ne qui veniam quodsi constituto, eum ei vocent referrentur vituperatoribus, melius verterem vituperatoribus vel an. Quo an partem antiopam. Legere postea maiestatis ea vel, pri an modus nominati mediocrem. Diam elit officiis an eos, eos doming malorum utroque te! Atqui nominavi forensibus cu mei, ipsum noluisse eu ius? Commune noluisse volutpat sea te, nec utinam albucius appellantur an. Ex petentium definitiones eos! Dico vero persius ex mel, sea malis ullum ex. Lobortis dissentiunt pro an, eum putant senserit cu, eos unum luptatum volutpat in. Iuvaret persecuti ex sit. Pertinacia consequuntur ut vel, nec saepe animal imperdiet cu. Agam illud ut nec, ea vidisse appetere mandamus pro, ea verterem pericula est. Sea ne dolores definiebas, pri et eirmod epicuri argumentum. Nostro discere prodesset eam ea, debet offendit ut duo. Mea case iisque disputando no? Nec consul mediocrem et. Sumo disputationi ex vim, te sit inermis lucilius inimicus, eam eu ludus propriae cotidieque. Ea putant nominavi pri! Senserit eloquentiam ei his. Est eu solum gubergren! Vix an omnis facer, enim adhuc sonet id eum. Sea minim semper alterum id? Vel ut nominavi officiis, mollis salutatus consulatu vis id. Porro lobortis has id? Assum everti viderer nam ut? Nam pertinacia forensibus te, no cibo habemus ocurreret mei. Nibh maluisset intellegebat est ei! Ad nec commodo oportere, ad mei congue maiorum? Ne vix quem dicta, causae doctus feugiat mei no, commune argumentum his ei! Eirmod fabulas voluptaria eos et! Ea eam quot elit, aliquip sententiae qui at. Lorem ipsum dolor sit amet, partem vituperatoribus ad usu, pro te quas virtute deserunt! Et quando iriure sit, ius an scripta blandit deseruisse. Ei vix justo vituperatoribus, te aliquid salutatus mel, his sanctus detracto invenire ex. Eos ei quando facete? Nam etiam elitr laboramus in. At erroribus corrumpit intellegat nec, vel te mundi atomorum molestiae! No pro alii senserit comprehensam, sit melius suscipit ea? Audiam officiis expetendis cum ad, ea ludus urbanitas vel, ad molestie detraxit necessitatibus mei. Quo reque propriae atomorum ne. Partem detraxit vituperatoribus qui eu, mel ad fugit temporibus, usu eu graecis singulis instructior. Ne soluta semper electram duo, pro paulo viris eu, ut tota timeam reprehendunt nec. No zril euismod maluisset per, quod aperiam placerat ad nec! Ei eum clita epicurei, te his expetenda intellegam, an simul pericula est. Eum suavitate efficiantur te, vidit ocurreret has ne. Quodsi omnium albucius quo id, dicat homero partem no sed? At est novum denique, qui in mundi atomorum, cu dicunt expetendis efficiantur pro. Amet tractatos repudiandae et vis, everti omnium voluptaria at vel. Mel modus electram et, ea mutat denique elaboraret vim. Per ei quas doming molestiae. Tation nonumes et usu, meis dolorem in eos. Ex reque doming vocibus ius, sit id vitae veritus. At sale magna quo. Et sumo tota pri? Quo ut novum iisque. Probo convenire et eum, te everti vocibus mel? Quas soleat has in, ex hinc indoctum vim! Cu homero sanctus legimus his. Mei enim phaedrum cotidieque te? In his veri conceptam. Vix et stet tractatos erroribus, ea quas fabellas qui. Id tacimates referrentur necessitatibus pri, sit summo congue ea. Quo hinc appetere postulant id, sit eu sint integre discere, pri alienum officiis te! Facete numquam ius in, mei no delectus convenire! Assum tempor in vis, te ridens assentior comprehensam quo? Illud aliquam est te, laudem libris iuvaret eos eu. Elitr quaerendum an his, vel quod purto etiam te. Eu has patrioque abhorreant. Sit minim scripserit at. Latine bonorum explicari an sed, mel cu ridens saperet iudicabit. An eum audiam corpora. Recusabo postulant vix ex, mea ad partem ornatus. Quando quaestio percipitur pri at, nostrud numquam repudiandae et nam. Erroribus patrioque rationibus vix ut, autem veniam semper eos id? Legimus nominati ea duo, ut accumsan copiosae perfecto usu, duo an quem minimum blandit! Te vix iudico sensibus partiendo, vero ocurreret in eos! Vis posse dicunt et? Sed mutat maiestatis at, at stet suavitate duo? His in fastidii legendos molestiae? Sea et deserunt consequat! Ad viris graecis invidunt pri, ea nullam quodsi consectetuer sea, mea ex zril phaedrum mandamus. Pro esse duis ad. At vel albucius tractatos, amet error erroribus ad vis? His putant nominavi indoctum ne, no munere aperiri consequat sed, id nec facilisis maiestatis signiferumque. His quot modus melius an. Mea ex tale feugait splendide, idque consul euismod eam ei, oblique interesset ea eam. Soleat liberavisse vis ea. Cu mel quis virtute, nec discere civibus mentitum ex, ei recusabo invenire cum. Ut eam cetero oporteat, unum option vituperata cu usu. Et eum everti doctus. Eu mazim veritus reprehendunt qui? Modo doctus neglegentur his in. Vis ea modus quidam constituto, tamquam integre moderatius quo eu, no duo amet etiam conclusionemque. Sed ea nusquam eligendi, no erant labores volumus sit. Eu cum virtute fuisset inimicus, dolore assentior cu his. Eius gloriatur interesset eu usu. Vis magna inciderint cu, mea idque voluptatum reprehendunt te? Vivendo epicuri eu eum, adhuc complectitur nam no. Usu eripuit adolescens eu, in eum democritum efficiendi! Ea nullam persius sed, case aliquid scribentur ut est. Ad minim voluptatum eam, cu alterum convenire vis, cum no agam dicunt vituperatoribus? At ius justo dissentiet eloquentiam! Vim ne oblique nostrum gloriatur, qui cu appetere petentium repudiandae? Ne qui veniam quodsi constituto, eum ei vocent referrentur vituperatoribus, melius verterem vituperatoribus vel an. Quo an partem antiopam. Legere postea maiestatis ea vel, pri an modus nominati mediocrem. Diam elit officiis an eos, eos doming malorum utroque te! Atqui nominavi forensibus cu mei, ipsum noluisse eu ius? Commune noluisse volutpat sea te, nec utinam albucius appellantur an. Ex petentium definitiones eos! Dico vero persius ex mel, sea malis ullum ex. Lobortis dissentiunt pro an, eum putant senserit cu, eos unum luptatum volutpat in. Iuvaret persecuti ex sit. Pertinacia consequuntur ut vel, nec saepe animal imperdiet cu. Agam illud ut nec, ea vidisse appetere mandamus pro, ea verterem pericula est. Sea ne dolores definiebas, pri et eirmod epicuri argumentum. Nostro discere prodesset eam ea, debet offendit ut duo. Mea case iisque disputando no? Nec consul mediocrem et. Sumo disputationi ex vim, te sit inermis lucilius inimicus, eam eu ludus propriae cotidieque. Ea putant nominavi pri! Senserit eloquentiam ei his. Est eu solum gubergren! Vix an omnis facer, enim adhuc sonet id eum. Sea minim semper alterum id? Vel ut nominavi officiis, mollis salutatus consulatu vis id. Porro lobortis has id? Assum everti viderer nam ut? Nam pertinacia forensibus te, no cibo habemus ocurreret mei. Nibh maluisset intellegebat est ei! Ad nec commodo oportere, ad mei congue maiorum? Ne vix quem dicta, causae doctus feugiat mei no, commune argumentum his ei! Eirmod fabulas voluptaria eos et! Ea eam quot elit, aliquip sententiae qui at. diff --git a/filters/vectors/wrap-unicode.expected b/filters/vectors/wrap-unicode.expected new file mode 100644 index 0000000..cff4e86 --- /dev/null +++ b/filters/vectors/wrap-unicode.expected @@ -0,0 +1,16 @@ +Λορεμ ιπσθμ δολορ σιτ αμετ, ρεβθμ φαλλι γραεcισ θτ θσθ, θσθ ομνισ μοδθσ +ατομορθμ ει, δθο εραντ πραεσεντ νο. Εξ εθμ μολεστιαε ιντελλεγαμ, σεα λαβιτθρ +αλιενθμ τε. Θσθ αν τεμπορ φορενσιβθσ, σιτ διcτα διcερετ ποσιδονιθμ ατ. Σενσεριτ +δισσεντιθντ ει μελ, φεθγιατ πλαcερατ περ cθ. Εα σιτ μοδθσ νονθμυ μελιορε, +ιντεγρε θλλαμcορπερ νε cθμ. Εα νεc σαεπε μανδαμθσ, qθισ vολθπτθα cονσθλατθ νο +vελ. Ηισ cθ νεμορε ποσσιμ. + +Αν προ φαcερ αργθμεντθμ, ατ μαλορθμ ιμπερδιετ ιντελλεγαμ θσθ, αδ πρι λθcιλιθσ +σcριπσεριτ. Θσθ ιν σολθμ διcατ δεμοcριτθμ, σιμθλ σcριπσεριτ εθ μει, vιξ εξ +ειρμοδ αccθσατα. Qθι ιμπεδιτ cοπιοσαε ιμπερδιετ εα, αφφερτ ορνατθσ ηισ εθ, +αεqθε τολλιτ cονσεcτετθερ νε προ! Ιπσθμ σεντεντιαε ετ προ, αθτεμ σθαvιτατε +cονστιτθαμ εξ qθι? Ταντασ λεγερε qθι ιδ? + +Θσθ νισλ νιηιλ ηενδρεριτ τε! Ιπσθμ νθσqθαμ ιθσ εξ? Ηισ αν ιπσθμ λατινε +δισσεντιθντ. Vιμ αλιqθιδ τεμποριβθσ vολθπτατιβθσ αδ, αδ πρι δομινγ απεριρι +δισπθτατιονι. Vιμ σθμμο αφφερτ εα, νονθμυ ποσσιτ φαβθλασ ατ εστ. diff --git a/filters/vectors/wrap-unicode.in b/filters/vectors/wrap-unicode.in new file mode 100644 index 0000000..e085ec7 --- /dev/null +++ b/filters/vectors/wrap-unicode.in @@ -0,0 +1,5 @@ +Λορεμ ιπσθμ δολορ σιτ αμετ, ρεβθμ φαλλι γραεcισ θτ θσθ, θσθ ομνισ μοδθσ ατομορθμ ει, δθο εραντ πραεσεντ νο. Εξ εθμ μολεστιαε ιντελλεγαμ, σεα λαβιτθρ αλιενθμ τε. Θσθ αν τεμπορ φορενσιβθσ, σιτ διcτα διcερετ ποσιδονιθμ ατ. Σενσεριτ δισσεντιθντ ει μελ, φεθγιατ πλαcερατ περ cθ. Εα σιτ μοδθσ νονθμυ μελιορε, ιντεγρε θλλαμcορπερ νε cθμ. Εα νεc σαεπε μανδαμθσ, qθισ vολθπτθα cονσθλατθ νο vελ. Ηισ cθ νεμορε ποσσιμ. + +Αν προ φαcερ αργθμεντθμ, ατ μαλορθμ ιμπερδιετ ιντελλεγαμ θσθ, αδ πρι λθcιλιθσ σcριπσεριτ. Θσθ ιν σολθμ διcατ δεμοcριτθμ, σιμθλ σcριπσεριτ εθ μει, vιξ εξ ειρμοδ αccθσατα. Qθι ιμπεδιτ cοπιοσαε ιμπερδιετ εα, αφφερτ ορνατθσ ηισ εθ, αεqθε τολλιτ cονσεcτετθερ νε προ! Ιπσθμ σεντεντιαε ετ προ, αθτεμ σθαvιτατε cονστιτθαμ εξ qθι? Ταντασ λεγερε qθι ιδ? + +Θσθ νισλ νιηιλ ηενδρεριτ τε! Ιπσθμ νθσqθαμ ιθσ εξ? Ηισ αν ιπσθμ λατινε δισσεντιθντ. Vιμ αλιqθιδ τεμποριβθσ vολθπτατιβθσ αδ, αδ πρι δομινγ απεριρι δισπθτατιονι. Vιμ σθμμο αφφερτ εα, νονθμυ ποσσιτ φαβθλασ ατ εστ. diff --git a/filters/wrap.c b/filters/wrap.c new file mode 100644 index 0000000..56560d2 --- /dev/null +++ b/filters/wrap.c @@ -0,0 +1,584 @@ +/* SPDX-License-Identifier: MIT */ +/* Copyright (c) 2023 Robin Jarry */ + +#define _XOPEN_SOURCE 700 +#include <errno.h> +#include <getopt.h> +#include <langinfo.h> +#include <locale.h> +#include <regex.h> +#include <stdbool.h> +#include <stdint.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <wchar.h> +#include <wctype.h> + +#ifdef __APPLE__ +#include <xlocale.h> +#endif + +static void usage(void) +{ + puts("usage: wrap [-h] [-w INT] [-r] [-l INT] [-f FILE]"); + puts(""); + puts("Wrap text without messing up email quotes."); + puts(""); + puts("options:"); + puts(" -h show this help message"); + puts(" -w INT preferred wrap margin (default 80)"); + puts(" -r reflow all paragraphs even if no trailing space"); + puts(" -l INT minimum percentage of letters in a line to be"); + puts(" considered a paragraph"); + puts(" -f FILE read from filename (default stdin)"); +} + +static size_t margin = 80; +static size_t prose_ratio = 50; +static bool reflow; +static FILE *in_file; + +static int parse_args(int argc, char **argv) +{ + const char *filename = NULL; + long value; + int c; + + while ((c = getopt(argc, argv, "hrw:l:f:")) != -1) { + errno = 0; + switch (c) { + case 'r': + reflow = true; + break; + case 'l': + value = strtol(optarg, NULL, 10); + if (errno) { + perror("error: invalid ratio value"); + return 1; + } + if (value <= 0 || value >= 100) { + fprintf(stderr, "error: ratio must be ]0,100[\n"); + return 1; + } + prose_ratio = (size_t)value; + break; + case 'w': + value = strtol(optarg, NULL, 10); + if (errno) { + perror("error: invalid width value"); + return 1; + } + if (value < 1) { + fprintf(stderr, "error: width must be positive\n"); + return 1; + } + margin = (size_t)value; + break; + case 'f': + filename = optarg; + break; + default: + usage(); + return 1; + } + } + if (optind < argc) { + fprintf(stderr, "%s: unexpected argument -- '%s'\n", + argv[0], argv[optind]); + usage(); + return 1; + } + if (filename == NULL || !strcmp(filename, "-")) { + in_file = stdin; + } else { + in_file = fopen(filename, "r"); + if (!in_file) { + perror("error: cannot open file"); + return 1; + } + } + return 0; +} + +static bool is_empty(const wchar_t *s) +{ + while (*s != L'\0') { + if (!iswspace((wint_t)*s++)) + return false; + } + return true; +} + +__attribute__((malloc,returns_nonnull)) +static void *xmalloc(size_t s) +{ + void *ptr = malloc(s); + if (ptr == NULL) { + perror("fatal: cannot allocate buffer"); + abort(); + } + return ptr; +} + +__attribute__((malloc,returns_nonnull)) +static void *xrealloc(void *ptr, size_t s) +{ + ptr = realloc(ptr, s); + if (ptr == NULL) { + perror("fatal: cannot reallocate buffer"); + abort(); + } + return ptr; +} + +struct paragraph { + /* email quote prefix, if any */ + wchar_t *quotes; + /* list item indent, if any */ + wchar_t *indent; + /* actual text of this paragraph */ + wchar_t *text; + /* percentage of letters in text */ + size_t prose_ratio; + /* text ends with a space */ + bool flowed; + /* paragraph is a list item */ + bool list_item; +}; + +static void free_paragraph(struct paragraph *p) +{ + if (!p) + return; + free(p->quotes); + free(p->indent); + free(p->text); + free(p); +} + +static wchar_t *read_part(const wchar_t *in, size_t len) +{ + wchar_t *out = xmalloc((len + 1) * sizeof(wchar_t)); + wcsncpy(out, in, len); + out[len] = L'\0'; + return out; +} + +static size_t list_item_offset(const wchar_t *buf) +{ + size_t i = 0; + wchar_t c; + + if (buf[i] == L'-' || buf[i] == '*' || buf[i] == '.') { + /* bullet list */ + i++; + } else if (iswdigit((wint_t)buf[i])) { + /* numbered list */ + i++; + if (iswdigit((wint_t)buf[i])) { + i++; + } + } else if (iswalpha((wint_t)buf[i])) { + /* lettered list */ + c = (wchar_t)towlower((wint_t)buf[i]); + i++; + if (c == L'i' || c == L'v') { + /* roman i. ii. iii. iv. ... */ + c = (wchar_t)towlower((wint_t)buf[i]); + while (i < 4 && (c == L'i' || c == L'v')) { + c = (wchar_t)towlower((wint_t)buf[++i]); + } + } + } else { + return 0; + } + if (iswdigit((wint_t)buf[0]) || iswalpha((wint_t)buf[0])) { + if (buf[i] == L')' || buf[i] == L'/' || buf[i] == L'.') { + i++; + } else { + return 0; + } + } + if (buf[i] == L' ') { + i++; + } else { + return 0; + } + + return i; +} + +static bool is_cjk(wchar_t c, bool include_syllables) { + /* CJK Radicals Supplement */ + if (c >= 0x2e80 && c <= 0x2fd5) + return true; + /* CJK Compatibility */ + if (c >= 0x3300 && c <= 0x33ff) + return true; + /* CJK Unified Ideographs Extension A */ + if (c >= 0x3400 && c <= 0x4db5) + return true; + /* CJK Unified Ideographs */ + if (c >= 0x4e00 && c <= 0x9fcb) + return true; + /* CJK Compatibility Ideographs */ + if (c >= 0xf900 && c <= 0xfa6a) + return true; + /* Hangul Jamo */ + if (c >= 0x1100 && c <= 0x11ff) + return true; + /* Hangul Compatibility Jamo */ + if (c >= 0x3130 && c <= 0x318f) + return true; + /* Hangul Jamo Extended-A */ + if (c >= 0xa960 && c <= 0xa97f) + return true; + /* Hangul Jamo Extended-B */ + if (c >= 0xd7b0 && c <= 0xd7ff) + return true; + + if (include_syllables) { + /* Japanese Hiragana */ + if (c >= 0x3040 && c <= 0x309f) + return true; + /* Japanese Katakana */ + if (c >= 0x30a0 && c <= 0x30ff) + return true; + /* Hangul Syllables */ + if (c >= 0xac00 && c <= 0xd7af) + return true; + } + return false; +} + +static struct paragraph *parse_line(const wchar_t *buf) +{ + size_t i, q, t, e, letters, indent_len, text_len; + bool list_item, flowed; + struct paragraph *p; + + /* + * Find relevant positions in the line: + * + * '> > > > 2) blah blah blah blah ' + * ^ ^ ^ ^ + * 0 q t e + * <------><-------------> + * quotes indent + * <--------------------------------> + * text + */ + + /* detect the end of quotes prefix if any */ + q = 0; + while (buf[q] == L'>') { + q++; + if (buf[q] == L' ') { + q++; + } + } + /* detect list item prefix & indent */ + t = q; + while (iswspace((wint_t)buf[t])) { + t++; + } + i = list_item_offset(&buf[t]); + list_item = i != 0; + t += i; + while (iswspace((wint_t)buf[t])) { + t++; + } + indent_len = t - q; + /* compute prose ratio */ + e = t; + letters = 0; + while (buf[e] != L'\0') { + wchar_t c = buf[e++]; + if (iswalpha((wint_t)c) || is_cjk(c, true)) { + letters++; + } + } + /* strip trailing whitespace unless it is a signature delimiter */ + flowed = false; + if (wcscmp(&buf[q], L"-- ") != 0) { + while (e > q && iswspace((wint_t)buf[e - 1])) { + e--; + flowed = true; + } + } + text_len = e - q; + + p = xmalloc(sizeof(*p)); + memset(p, 0, sizeof(*p)); + p->quotes = read_part(buf, q); + p->indent = xmalloc((indent_len + 1) * sizeof(wchar_t)); + for (i = 0; i < indent_len; i++) + p->indent[i] = L' '; + p->indent[i] = L'\0'; + p->text = read_part(&buf[q], text_len); + p->flowed = flowed; + p->list_item = list_item; + p->prose_ratio = 100 * letters / (text_len ? text_len : 1); + + return p; +} + +static bool is_continuation( + const struct paragraph *p, const struct paragraph *next +) { + if (next->list_item) + /* new list items always start a new paragraph */ + return false; + if (next->prose_ratio < prose_ratio || p->prose_ratio < prose_ratio) + /* does not look like prose, maybe ascii art */ + return false; + if (wcscmp(next->quotes, p->quotes) != 0) + /* quote prefix has changed */ + return false; + if (wcscmp(next->indent, p->indent) != 0) + /* list item indent has changed */ + return false; + if (is_empty(next->text)) + /* empty or whitespace only line */ + return false; + if (wcscmp(p->text, L"--") == 0 || wcscmp(p->text, L"-- ") == 0) + /* never join anything with signature start */ + return false; + if (p->flowed) + /* current paragraph has trailing space, indicating + * format=flowed */ + return true; + if (reflow) + /* user forced paragraph reflow on the command line */ + return true; + return false; +} + +static void join_paragraph( + struct paragraph *p, const struct paragraph *next +) { + const wchar_t *append = next->text; + const wchar_t *separator = L" "; + size_t len, extra_len; + wchar_t *text; + + /* trim leading whitespace of the next paragraph before joining */ + while (*append != L'\0' && iswspace((wint_t)*append)) + append++; + + len = wcslen(p->text); + if (len == 0) { + separator = L""; + } + extra_len = wcslen(separator) + wcslen(append) + 1; + + text = xrealloc(p->text, (len + extra_len) * sizeof(wchar_t)); + swprintf(&text[len], extra_len, L"%ls%ls", separator, append); + + p->text = text; + p->prose_ratio = (p->prose_ratio + next->prose_ratio) / 2; + p->flowed = next->flowed; +} + +/* + * BUFSIZ has different values depending on the libc implementation. + * Use a self defined value to have consistent behaviour across all platforms. + */ +#define BUFFER_SIZE 8192 + +/* + * Check if a line can be split at the given character point. + */ +static bool is_split_point(const wchar_t c) +{ + if (iswspace((wint_t)c)) + return true; + + if (is_cjk(c, false)) + return true; + + return false; +} + +/* + * Write a paragraph, wrapping at words boundaries. + * + * Only try to do word wrapping on things that look like prose. When the text + * contains too many non-letter characters, print it as-is. + */ +static void write_paragraph(struct paragraph *p) +{ + size_t quotes_width = (size_t)wcswidth(p->quotes, wcslen(p->quotes)); + size_t remain = (size_t)wcswidth(p->text, wcslen(p->text)); + const wchar_t *indent = L""; + wchar_t *text = p->text; + bool more = true; + int wchar_count; + wchar_t *line; + size_t width; + + while (more) { + width = quotes_width + (size_t)wcswidth(indent, wcslen(indent)); + + if (width + remain <= margin || p->prose_ratio < prose_ratio) { + /* whole paragraph fits on a single line */ + line = text; + wchar_count = (int)wcslen(text); + more = false; + } else { + /* find split point, preferably before margin */ + size_t split = SIZE_MAX; + size_t w = 0; + for (size_t i = 0; text[i] != L'\0'; i++) { + w += (size_t)wcwidth(text[i]); + if (width + w > margin && split != SIZE_MAX) { + break; + } + if (is_split_point(text[i])) { + split = i; + } + } + if (split == SIZE_MAX) { + /* no space found to split, print a long line */ + line = text; + wchar_count = (int)wcslen(text); + more = false; + } else { + wchar_count = (int)split; + line = text; + /* find start of next word */ + while (iswspace((wint_t)text[split])) { + split++; + } + if (text[split] != L'\0') { + remain -= (size_t)wcswidth(text, split); + text = &text[split]; + } else { + /* only trailing whitespace, we're done */ + more = false; + } + } + } + wprintf(L"%ls%ls%.*ls\n", p->quotes, indent, wchar_count, line); + indent = p->indent; + } +} + +#define SPACES_PER_TAB 8 + +/* + * Trim LF CR CRLF LFCR and replace tabs with spaces. + */ +static void sanitize_line(const wchar_t *in, wchar_t *out) +{ + /* No bounds checking needed. This function is only used with + * 'buf' and 'line' buffers from main. 'out' is large enough no + * matter what is present in 'in'. */ + while (*in != L'\0' && *in != L'\n' && *in != L'\r') { + if (*in == L'\t') { + /* tabs cause indentation/alignment issues + * replace them with 8 spaces */ + in++; + for (int i = 0; i < SPACES_PER_TAB; i++) + *out++ = L' '; + } else { + *out++ = *in++; + } + } + *out = L'\0'; +} + +static int set_stdio_encoding(void) +{ + const char *locale = setlocale(LC_ALL, ""); + + if (!locale) { + /* Neither LC_ALL nor LANG env vars are defined or are set to + * a non existent/installed locale. Try with a generic UTF-8 + * locale which is expected to be available on all POSIX + * systems. */ + locale = setlocale(LC_ALL, "C.UTF-8"); + if (!locale) { + /* The system is not following POSIX standards. Last + * resort: check if 'UTF-8' (encoding only) exists. */ + locale = setlocale(LC_CTYPE, "UTF-8"); + } + } + if (!locale) { + perror("error: failed to set locale"); + return 1; + } + + /* aerc will always send UTF-8 text, ensure that we read that properly */ + locale_t loc = newlocale(LC_ALL_MASK, locale, NULL); + char *codeset = nl_langinfo_l(CODESET, loc); + freelocale(loc); + if (!strstr(codeset, "UTF-8")) { + fprintf(stderr, "error: locale '%s' is not UTF-8\n", locale); + return 1; + } + + /* ensure files are configured to read/write wide characters */ + fwide(in_file, true); + fwide(stdout, true); + + return 0; +} + +int main(int argc, char **argv) +{ + /* line needs to be 8 times larger than buf since every read character + * may be a tab (very unlikely, but it could happen). */ + static wchar_t buf[BUFFER_SIZE], line[BUFFER_SIZE * SPACES_PER_TAB]; + struct paragraph *cur = NULL, *next; + bool is_patch = false; + regmatch_t groups[2]; + char *subject; + regex_t re; + int err; + + err = parse_args(argc, argv); + if (err) + goto end; + + regcomp(&re, "\\<PATCH\\>", REG_EXTENDED); + subject = getenv("AERC_SUBJECT"); + if (subject && !regexec(&re, subject, 2, groups, 0)) + is_patch = true; + regfree(&re); + + err = set_stdio_encoding(); + if (err) + goto end; + + while (fgetws(buf, BUFFER_SIZE, in_file)) { + if (is_patch) { + /* never reflow patches */ + fputws(buf, stdout); + continue; + } + sanitize_line(buf, line); + next = parse_line(line); + if (!cur) { + cur = next; + } else if (is_continuation(cur, next)) { + join_paragraph(cur, next); + free_paragraph(next); + } else { + write_paragraph(cur); + free_paragraph(cur); + cur = next; + } + } + if (cur) { + write_paragraph(cur); + } + +end: + free_paragraph(cur); + if (in_file) { + fclose(in_file); + } + return err; +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..762bb48 --- /dev/null +++ b/go.mod @@ -0,0 +1,57 @@ +module git.sr.ht/~rjarry/aerc + +go 1.21 + +require ( + git.sr.ht/~rjarry/go-opt/v2 v2.0.1 + git.sr.ht/~rockorager/go-jmap v0.5.0 + git.sr.ht/~rockorager/vaxis v0.11.1 + github.com/ProtonMail/go-crypto v1.1.4 + github.com/arran4/golang-ical v0.3.1 + github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 + github.com/emersion/go-imap v1.2.1 + github.com/emersion/go-imap-sortthread v1.2.0 + github.com/emersion/go-maildir v0.5.0 + github.com/emersion/go-mbox v1.0.3 + github.com/emersion/go-message v0.18.2 + github.com/emersion/go-msgauth v0.6.8 + github.com/emersion/go-pgpmail v0.2.2 + github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 + github.com/emersion/go-smtp v0.21.3 + github.com/fsnotify/fsevents v0.2.0 + github.com/fsnotify/fsnotify v1.8.0 + github.com/gatherstars-com/jwz v1.4.0 + github.com/go-ini/ini v1.67.0 + github.com/lithammer/fuzzysearch v1.1.8 + github.com/mattn/go-isatty v0.0.20 + github.com/mattn/go-runewidth v0.0.16 + github.com/pkg/errors v0.9.1 + github.com/riywo/loginshell v0.0.0-20200815045211-7d26008be1ab + github.com/stretchr/testify v1.10.0 + github.com/syndtr/goleveldb v1.0.0 + golang.org/x/image v0.23.0 + golang.org/x/oauth2 v0.24.0 + golang.org/x/sys v0.28.0 + golang.org/x/tools v0.24.0 +) + +require ( + github.com/cloudflare/circl v1.4.0 // indirect + github.com/containerd/console v1.0.4 // indirect + github.com/creack/pty v1.1.24 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/golang/snappy v0.0.4 // indirect + github.com/kr/pretty v0.3.0 // indirect + github.com/mattn/go-sixel v0.0.5 // indirect + github.com/onsi/gomega v1.20.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/rogpeppe/go-internal v1.8.1 // indirect + github.com/soniakeys/quant v1.0.0 // indirect + golang.org/x/crypto v0.31.0 // indirect + golang.org/x/mod v0.20.0 // indirect + golang.org/x/sync v0.10.0 // indirect + golang.org/x/text v0.21.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..536f465 --- /dev/null +++ b/go.sum @@ -0,0 +1,232 @@ +git.sr.ht/~rjarry/go-opt/v2 v2.0.1 h1:rNag0btxzpPN9FOPEqJfmFY70R9Zqf7M1lbNdy6+jvM= +git.sr.ht/~rjarry/go-opt/v2 v2.0.1/go.mod h1:ZIcXh1fUrJEE5bdfaOpx5Uk9YURsimePQ7JJpitDZq4= +git.sr.ht/~rockorager/go-jmap v0.5.0 h1:Xs8NeqpA631HUz4uIe6V+0CpWt6b+nnHF7S14U2BVPA= +git.sr.ht/~rockorager/go-jmap v0.5.0/go.mod h1:aOTCtwpZSINpDDSOkLGpHU0Kbbm5lcSDMcobX3ZtOjY= +git.sr.ht/~rockorager/vaxis v0.11.1 h1:nPcC4sW8haKZKbyv76GPGQ5bmMWkPNhoNI726hQPM1A= +git.sr.ht/~rockorager/vaxis v0.11.1/go.mod h1:h94aKek3frIV1hJbdXjqnBqaLkbWXvV+UxAsQHg9bns= +github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= +github.com/ProtonMail/go-crypto v1.1.4 h1:G5U5asvD5N/6/36oIw3k2bOfBn5XVcZrb7PBjzzKKoE= +github.com/ProtonMail/go-crypto v1.1.4/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/arran4/golang-ical v0.3.1 h1:v13B3eQZ9VDHTAvT6M11vVzxYgcYmjyPBE2eAZl3VZk= +github.com/arran4/golang-ical v0.3.1/go.mod h1:LZWxF8ZIu/sjBVUCV0udiVPrQAgq3V0aa0RfbO99Qkk= +github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= +github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI= +github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8= +github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= +github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= +github.com/cloudflare/circl v1.4.0 h1:BV7h5MgrktNzytKmWjpOtdYrf0lkkbF8YMlBGPhJQrY= +github.com/cloudflare/circl v1.4.0/go.mod h1:PDRU+oXvdD7KCtgKxW95M5Z8BpSCJXQORiZFnBQS5QU= +github.com/containerd/console v1.0.4 h1:F2g4+oChYvBTsASRTz8NP6iIAi97J3TtSAsLbIFn4ro= +github.com/containerd/console v1.0.4/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ= +github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emersion/go-imap v1.0.5/go.mod h1:yKASt+C3ZiDAiCSssxg9caIckWF/JG7ZQTO7GAmvicU= +github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA= +github.com/emersion/go-imap v1.2.1/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY= +github.com/emersion/go-imap-sortthread v1.2.0 h1:EMVEJXPWAhXMWECjR82Rn/tza6MddcvTwGAdTu1vJKU= +github.com/emersion/go-imap-sortthread v1.2.0/go.mod h1:UhenCBupR+vSYRnqJkpjSq84INUCsyAK1MLpogv14pE= +github.com/emersion/go-maildir v0.5.0 h1:RhmSIKAvdSbKCicpe8lrlihjS/xLx0CzWIWJZQQyG4k= +github.com/emersion/go-maildir v0.5.0/go.mod h1:Wpgtt9EOIJWe++WKa+JRvDwv+qIV7MeFdvZu/VbsXN4= +github.com/emersion/go-mbox v1.0.3 h1:Kac75r/EGi6KZAz48HXal9q7EiaXNl+U5HZfyDz0LKM= +github.com/emersion/go-mbox v1.0.3/go.mod h1:Yp9IVuuOYLEuMv4yjgDHvhb5mHOcYH6x92Oas3QqEZI= +github.com/emersion/go-message v0.11.1/go.mod h1:C4jnca5HOTo4bGN9YdqNQM9sITuT3Y0K6bSUw9RklvY= +github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4= +github.com/emersion/go-message v0.17.0/go.mod h1:/9Bazlb1jwUNB0npYYBsdJ2EMOiiyN3m5UVHbY7GoNw= +github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg= +github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA= +github.com/emersion/go-msgauth v0.6.8 h1:kW/0E9E8Zx5CdKsERC/WnAvnXvX7q9wTHia1OA4944A= +github.com/emersion/go-msgauth v0.6.8/go.mod h1:YDwuyTCUHu9xxmAeVj0eW4INnwB6NNZoPdLerpSxRrc= +github.com/emersion/go-pgpmail v0.2.2 h1:cO2jwsE0gb8aDdCcVH5Dfe1XV3Rhhw2GVWsmQd3CbaI= +github.com/emersion/go-pgpmail v0.2.2/go.mod h1:mRB5P7QKiAuOvcT36tdRZvm7nSt7V+f6jbzzup3HuvU= +github.com/emersion/go-sasl v0.0.0-20191210011802-430746ea8b9b/go.mod h1:G/dpzLu16WtQpBfQ/z3LYiYJn3ZhKSGWn83fyoyQe/k= +github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk= +github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/emersion/go-smtp v0.21.3 h1:7uVwagE8iPYE48WhNsng3RRpCUpFvNl39JGNSIyGVMY= +github.com/emersion/go-smtp v0.21.3/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ= +github.com/emersion/go-textwrapper v0.0.0-20160606182133-d0e65e56babe/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U= +github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U= +github.com/fsnotify/fsevents v0.2.0 h1:BRlvlqjvNTfogHfeBOFvSC9N0Ddy+wzQCQukyoD7o/c= +github.com/fsnotify/fsevents v0.2.0/go.mod h1:B3eEk39i4hz8y1zaWS/wPrAP4O6wkIl7HQwKBr1qH/w= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= +github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gatherstars-com/jwz v1.4.0 h1:HrCJmTss6/PTzBxxQUGbJ5f0aFYjnfrfqpGlyWH+R5A= +github.com/gatherstars-com/jwz v1.4.0/go.mod h1:twtXjMamfC5/NRCTJ9vDiHGeDivORkTAvOMUX/qo0Ik= +github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= +github.com/gdamore/tcell/v2 v2.6.0/go.mod h1:be9omFATkdr0D9qewWW3d+MEvl5dha+Etb5y65J2H8Y= +github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= +github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= +github.com/go-test/deep v1.0.7/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8= +github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14= +github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs= +github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk= +github.com/jaytaylor/html2text v0.0.0-20211105163654-bc68cce691ba h1:QFQpJdgbON7I0jr2hYW7Bs+XV0qjc3d5tZoDnRFnqTg= +github.com/jaytaylor/html2text v0.0.0-20211105163654-bc68cce691ba/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk= +github.com/jhillyerd/enmime v0.11.0 h1:5EOSLh7l3eMODznfMCKVGQY74Qb95Yfuet6CYgquLfM= +github.com/jhillyerd/enmime v0.11.0/go.mod h1:nw2aJ34YXWklLze+qEESgP+KNhU3fMQuiFsD/4soh3Q= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= +github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/martinlindhe/base36 v1.0.0/go.mod h1:+AtEs8xrBpCeYgSLoY/aJ6Wf37jtBuR0s35750M27+8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sixel v0.0.5 h1:55w2FR5ncuhKhXrM5ly1eiqMQfZsnAHIpYNGZX03Cv8= +github.com/mattn/go-sixel v0.0.5/go.mod h1:h2Sss+DiUEHy0pUqcIB6PFXo5Cy8sTQEFr3a9/5ZLNw= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.20.0 h1:8W0cWlwFkflGPLltQvLRB7ZVD5HuP6ng320w2IS245Q= +github.com/onsi/gomega v1.20.0/go.mod h1:DtrZpjmvpn2mPm4YWQa0/ALMDj9v4YxLgojwPeREyVo= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/tview v0.0.0-20230226195229-47e7db7885b4/go.mod h1:nVwGv4MP47T0jvlk7KuTTjjuSmrGO4JF0iaiNt4bufE= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/riywo/loginshell v0.0.0-20200815045211-7d26008be1ab h1:ZjX6I48eZSFetPb41dHudEyVr5v953N15TsNZXlkcWY= +github.com/riywo/loginshell v0.0.0-20200815045211-7d26008be1ab/go.mod h1:/PfPXh0EntGc3QAAyUaviy4S9tzy4Zp0e2ilq4voC6E= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg= +github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= +github.com/soniakeys/quant v1.0.0 h1:N1um9ktjbkZVcywBVAAYpZYSHxEfJGzshHCxx/DaI0Y= +github.com/soniakeys/quant v1.0.0/go.mod h1:HI1k023QuVbD4H8i9YdfZP2munIHU4QpjsImz6Y6zds= +github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo= +github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE= +github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= +golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= +golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0= +golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= +golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= +golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE= +golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24= +golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/lib/attachment.go b/lib/attachment.go new file mode 100644 index 0000000..63b8f16 --- /dev/null +++ b/lib/attachment.go @@ -0,0 +1,192 @@ +package lib + +import ( + "bufio" + "bytes" + "io" + "mime" + "net/http" + "os" + "path/filepath" + "strings" + + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/xdg" + "github.com/emersion/go-message/mail" + "github.com/pkg/errors" +) + +type Part struct { + MimeType string + Params map[string]string + Data []byte + Converted bool + ConversionError error +} + +func NewPart(mimetype string, params map[string]string, body io.Reader, +) (*Part, error) { + var d []byte + var err error + var converted bool + if body == nil { + converted = true + } else { + d, err = io.ReadAll(body) + if err != nil { + return nil, err + } + } + return &Part{ + MimeType: mimetype, + Params: params, + Data: d, + Converted: converted, + }, nil +} + +func (p *Part) NewReader() io.Reader { + return bytes.NewReader(p.Data) +} + +type Attachment interface { + Name() string + WriteTo(w *mail.Writer) error +} + +type FileAttachment struct { + path string +} + +func NewFileAttachment(path string) *FileAttachment { + return &FileAttachment{ + path, + } +} + +func (fa *FileAttachment) Name() string { + return fa.path +} + +func (fa *FileAttachment) WriteTo(w *mail.Writer) error { + f, err := os.Open(xdg.ExpandHome(fa.path)) + if err != nil { + return errors.Wrap(err, "os.Open") + } + defer f.Close() + + reader := bufio.NewReader(f) + + mimeType, params, err := FindMimeType(fa.path, reader) + if err != nil { + return errors.Wrap(err, "ParseMediaType") + } + filename := filepath.Base(fa.path) + params["name"] = filename + + // set header fields + ah := mail.AttachmentHeader{} + ah.SetContentType(mimeType, params) + // setting the filename auto sets the content disposition + ah.SetFilename(filename) + + fixContentTransferEncoding(mimeType, &ah) + + aw, err := w.CreateAttachment(ah) + if err != nil { + return errors.Wrap(err, "CreateAttachment") + } + defer aw.Close() + + if _, err := reader.WriteTo(aw); err != nil { + return errors.Wrap(err, "reader.WriteTo") + } + + return nil +} + +type PartAttachment struct { + part *Part + name string +} + +func NewPartAttachment(part *Part, name string) *PartAttachment { + return &PartAttachment{ + part, + name, + } +} + +func (pa *PartAttachment) Name() string { + return pa.name +} + +func (pa *PartAttachment) WriteTo(w *mail.Writer) error { + // set header fields + ah := mail.AttachmentHeader{} + ah.SetContentType(pa.part.MimeType, pa.part.Params) + + // setting the filename auto sets the content disposition + ah.SetFilename(pa.Name()) + + fixContentTransferEncoding(pa.part.MimeType, &ah) + + aw, err := w.CreateAttachment(ah) + if err != nil { + return errors.Wrap(err, "CreateAttachment") + } + defer aw.Close() + + if _, err := io.Copy(aw, pa.part.NewReader()); err != nil { + return errors.Wrap(err, "io.Copy") + } + return nil +} + +// SetUtf8Charset sets the charset in a params map to UTF-8. +func SetUtf8Charset(origParams map[string]string) map[string]string { + params := make(map[string]string) + for k, v := range origParams { + switch strings.ToLower(k) { + case "charset": + log.Debugf("substitute charset %s with utf-8", v) + params[k] = "utf-8" + default: + params[k] = v + } + } + return params +} + +func FindMimeType(filename string, reader *bufio.Reader) (string, map[string]string, error) { + // if we have an extension, prefer that instead of trying to sniff the header. + // That's generally more accurate than sniffing as lots of things are zip files + // under the hood, e.g. most office file types + ext := filepath.Ext(filename) + var mimeString string + if mimeString = mime.TypeByExtension(ext); mimeString == "" { + // Sniff the mime type since it's not in the database + // http.DetectContentType only cares about the first 512 bytes + head, err := reader.Peek(512) + if err != nil && err != io.EOF { + return "", map[string]string{}, errors.Wrap(err, "Peek") + } + mimeString = http.DetectContentType(head) + } + + // mimeString can contain type and params (like text encoding), + // so we need to break them apart before passing them to the headers + return mime.ParseMediaType(mimeString) +} + +// fixContentTransferEncoding checks the mime type of the attachment and +// corrects the content-transfer-encoding if necessary. +// +// It's expressly forbidden by RFC2046 to set any other +// content-transfer-encoding than 7bit, 8bit, or binary for +// message/rfc822 mime types (see RFC2046, section 5.2.1) +func fixContentTransferEncoding(mimeType string, header *mail.AttachmentHeader) { + if strings.ToLower(mimeType) == "message/rfc822" { + header.Add("Content-Transfer-Encoding", "binary") + } +} diff --git a/lib/auth/auth.go b/lib/auth/auth.go new file mode 100644 index 0000000..ea32ecd --- /dev/null +++ b/lib/auth/auth.go @@ -0,0 +1,147 @@ +package auth + +import ( + "fmt" + "regexp" + "strings" + + "github.com/emersion/go-message/mail" + "github.com/emersion/go-msgauth/authres" +) + +const ( + AuthHeader = "Authentication-Results" +) + +type Method string + +const ( + DKIM Method = "dkim" + SPF Method = "spf" + DMARC Method = "dmarc" +) + +type Result string + +const ( + ResultNone Result = "none" + ResultPass Result = "pass" + ResultFail Result = "fail" + ResultNeutral Result = "neutral" + ResultPolicy Result = "policy" +) + +type Details struct { + Results []Result + Infos []string + Reasons []string + Err error +} + +func (d *Details) add(r Result, info string, reason string) { + d.Results = append(d.Results, r) + d.Infos = append(d.Infos, info) + d.Reasons = append(d.Reasons, reason) +} + +type ParserFunc func(*mail.Header, []string) (*Details, error) + +func New(s string) ParserFunc { + if i := strings.IndexRune(s, '+'); i > 0 { + s = s[:i] + } + m := Method(strings.ToLower(s)) + switch m { + case DKIM, SPF, DMARC: + return CreateParser(m) + } + return nil +} + +func trust(s string, trusted []string) bool { + for _, t := range trusted { + if matched, _ := regexp.MatchString(t, s); matched || t == "*" { + return true + } + } + return false +} + +var cleaner = regexp.MustCompile(`(\(.*);(.*\))`) + +func CreateParser(m Method) func(*mail.Header, []string) (*Details, error) { + return func(header *mail.Header, trusted []string) (*Details, error) { + details := &Details{} + found := false + + hf := header.FieldsByKey(AuthHeader) + for hf.Next() { + headerText, err := hf.Text() + if err != nil { + return nil, err + } + + identifier, results, err := authres.Parse(headerText) + // TODO: refactor to use errors.Is + switch { + case err != nil && err.Error() == "msgauth: unsupported version": + // Some MTA write their authres header without an identifier + // which does not conform to RFC but still exists in the wild + identifier, results, err = authres.Parse("unknown;" + headerText) + if err != nil { + return nil, err + } + case err != nil && err.Error() == "msgauth: malformed authentication method and value": + // the go-msgauth parser doesn't like semi-colons in the comments + // as a work-around we remove those + cleanHeader := cleaner.ReplaceAllString(headerText, "${1}${2}") + identifier, results, err = authres.Parse(cleanHeader) + if err != nil { + return nil, err + } + case err != nil: + return nil, err + } + + // implements recommendation from RFC 7601 Sec 7.1 to + // have an explicit list of trustworthy hostnames + // before displaying AuthRes results + if !trust(identifier, trusted) { + return nil, fmt.Errorf("%s is not trusted", identifier) + } + + for _, result := range results { + switch r := result.(type) { + case *authres.DKIMResult: + if m == DKIM { + info := r.Identifier + if info == "" && r.Domain != "" { + info = r.Domain + } + details.add(Result(r.Value), info, r.Reason) + found = true + } + case *authres.SPFResult: + if m == SPF { + info := r.From + if info == "" && r.Helo != "" { + info = r.Helo + } + details.add(Result(r.Value), info, r.Reason) + found = true + } + case *authres.DMARCResult: + if m == DMARC { + details.add(Result(r.Value), r.From, r.Reason) + found = true + } + } + } + } + + if !found { + details.add(ResultNone, "", "") + } + return details, nil + } +} diff --git a/lib/calendar/calendar.go b/lib/calendar/calendar.go new file mode 100644 index 0000000..a4a6201 --- /dev/null +++ b/lib/calendar/calendar.go @@ -0,0 +1,204 @@ +package calendar + +import ( + "bytes" + "fmt" + "io" + "net/mail" + "regexp" + "strings" + "time" + + ics "github.com/arran4/golang-ical" +) + +type Reply struct { + MimeType string + Params map[string]string + CalendarText io.ReadWriter + PlainText io.ReadWriter + Organizers []string +} + +func (cr *Reply) AddOrganizer(o string) { + cr.Organizers = append(cr.Organizers, o) +} + +// CreateReply parses a ics request and return a ics reply (RFC 2446, Section 3.2.3) +func CreateReply(reader io.Reader, from *mail.Address, partstat string) (*Reply, error) { + cr := Reply{ + MimeType: "text/calendar", + Params: map[string]string{ + "charset": "UTF-8", + "method": "REPLY", + }, + CalendarText: &bytes.Buffer{}, + PlainText: &bytes.Buffer{}, + } + + var ( + status ics.ParticipationStatus + action string + ) + + switch partstat { + case "accept": + status = ics.ParticipationStatusAccepted + action = "accepted" + case "accept-tentative": + status = ics.ParticipationStatusTentative + action = "tentatively accepted" + case "decline": + status = ics.ParticipationStatusDeclined + action = "declined" + default: + return nil, fmt.Errorf("participation status %s is not implemented", partstat) + } + + name := from.Name + if name == "" { + name = from.Address + } + fmt.Fprintf(cr.PlainText, "%s has %s this invitation.", name, action) + + invite, err := parse(reader) + if err != nil { + return nil, err + } + + if ok := invite.request(); !ok { + return nil, fmt.Errorf("no reply is requested") + } + + // update invite as a reply + reply := invite + reply.SetMethod(ics.MethodReply) + reply.SetProductId("aerc") + + // check all events + for _, vevent := range reply.Events() { + e := event{vevent} + + // check if we should answer + if err := e.isReplyRequested(from.Address); err != nil { + return nil, err + } + + // make sure we send our reply to the meeting organizer + if organizer := e.GetProperty(ics.ComponentPropertyOrganizer); organizer != nil { + cr.AddOrganizer(organizer.Value) + } + + // update attendee participation status + e.updateAttendees(status, from.Address) + + // update timestamp + e.SetDtStampTime(time.Now()) + + // remove any subcomponents of event + e.Components = nil + } + + // keep only timezone and event components + reply.clean() + + if len(reply.Events()) == 0 { + return nil, fmt.Errorf("no events to respond to") + } + + if err := reply.SerializeTo(cr.CalendarText); err != nil { + return nil, err + } + return &cr, nil +} + +type calendar struct { + *ics.Calendar +} + +func parse(reader io.Reader) (*calendar, error) { + // fix capitalized mailto for parsing of ics file + var sb strings.Builder + _, err := io.Copy(&sb, reader) + if err != nil { + return nil, fmt.Errorf("failed to copy calendar data: %w", err) + } + re := regexp.MustCompile("MAILTO:(.+@)") + str := re.ReplaceAllString(sb.String(), "mailto:${1}") + + // parse calendar + invite, err := ics.ParseCalendar(strings.NewReader(str)) + if err != nil { + return nil, err + } + return &calendar{invite}, nil +} + +func (cal *calendar) request() (ok bool) { + ok = false + for i := range cal.CalendarProperties { + if cal.CalendarProperties[i].IANAToken == string(ics.PropertyMethod) { + if cal.CalendarProperties[i].Value == string(ics.MethodRequest) { + ok = true + return + } + } + } + return +} + +func (cal *calendar) clean() { + var clean []ics.Component + for _, comp := range cal.Components { + switch comp.(type) { + case *ics.VTimezone, *ics.VEvent: + clean = append(clean, comp) + default: + continue + } + } + cal.Components = clean +} + +type event struct { + *ics.VEvent +} + +func (e *event) isReplyRequested(from string) error { + var present bool = false + var rsvp bool = false + from = strings.ToLower(from) + for _, a := range e.Attendees() { + if strings.ToLower(a.Email()) == from { + present = true + if r, ok := a.ICalParameters[string(ics.ParameterRsvp)]; ok { + if len(r) > 0 && strings.ToLower(r[0]) == "true" { + rsvp = true + } + } + } + } + if !present { + return fmt.Errorf("we are not invited") + } + if !rsvp { + return fmt.Errorf("we don't have to rsvp") + } + return nil +} + +func (e *event) updateAttendees(status ics.ParticipationStatus, from string) { + var clean []ics.IANAProperty + for _, prop := range e.Properties { + if prop.IANAToken == string(ics.ComponentPropertyAttendee) { + att := ics.Attendee{IANAProperty: prop} + if att.Email() != from { + continue + } + prop.ICalParameters[string(ics.ParameterParticipationStatus)] = []string{string(status)} + delete(prop.ICalParameters, string(ics.ParameterRsvp)) + } + clean = append(clean, prop) + } + e.Properties = clean +} diff --git a/lib/crypto/crypto.go b/lib/crypto/crypto.go new file mode 100644 index 0000000..af3d1e7 --- /dev/null +++ b/lib/crypto/crypto.go @@ -0,0 +1,60 @@ +package crypto + +import ( + "bytes" + "io" + + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/lib/crypto/gpg" + "git.sr.ht/~rjarry/aerc/lib/crypto/pgp" + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/models" + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/emersion/go-message/mail" +) + +type Provider interface { + Decrypt(io.Reader, openpgp.PromptFunction) (*models.MessageDetails, error) + Encrypt(*bytes.Buffer, []string, string, openpgp.PromptFunction, *mail.Header) (io.WriteCloser, error) + Sign(*bytes.Buffer, string, openpgp.PromptFunction, *mail.Header) (io.WriteCloser, error) + ImportKeys(io.Reader) error + Init() error + Close() + GetSignerKeyId(string) (string, error) + GetKeyId(string) (string, error) + ExportKey(string) (io.Reader, error) +} + +func New() Provider { + switch config.General.PgpProvider { + case "auto": + internal := &pgp.Mail{} + if internal.KeyringExists() { + log.Debugf("internal pgp keyring exists") + return internal + } + log.Debugf("no internal pgp keyring, using system gpg") + fallthrough + case "gpg": + return &gpg.Mail{} + case "internal": + return &pgp.Mail{} + default: + return nil + } +} + +func IsEncrypted(bs *models.BodyStructure) bool { + if bs == nil { + return false + } + if bs.MIMEType == "application" && bs.MIMESubType == "pgp-encrypted" { + return true + } + for _, part := range bs.Parts { + if IsEncrypted(part) { + return true + } + } + return false +} diff --git a/lib/crypto/gpg/gpg.go b/lib/crypto/gpg/gpg.go new file mode 100644 index 0000000..c73272a --- /dev/null +++ b/lib/crypto/gpg/gpg.go @@ -0,0 +1,69 @@ +package gpg + +import ( + "bytes" + "io" + "os/exec" + + "git.sr.ht/~rjarry/aerc/lib/crypto/gpg/gpgbin" + "git.sr.ht/~rjarry/aerc/models" + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/emersion/go-message/mail" +) + +// Mail satisfies the PGPProvider interface in aerc +type Mail struct{} + +func (m *Mail) Init() error { + _, err := exec.LookPath("gpg") + return err +} + +func (m *Mail) Decrypt(r io.Reader, decryptKeys openpgp.PromptFunction) (*models.MessageDetails, error) { + gpgReader, err := Read(r) + if err != nil { + return nil, err + } + md := gpgReader.MessageDetails + md.SignatureValidity = models.Valid + if md.SignatureError != "" { + md.SignatureValidity = handleSignatureError(md.SignatureError) + } + return md, nil +} + +func (m *Mail) ImportKeys(r io.Reader) error { + return gpgbin.Import(r) +} + +func (m *Mail) Encrypt(buf *bytes.Buffer, rcpts []string, signer string, decryptKeys openpgp.PromptFunction, header *mail.Header) (io.WriteCloser, error) { + return Encrypt(buf, header.Header.Header, rcpts, signer) +} + +func (m *Mail) Sign(buf *bytes.Buffer, signer string, decryptKeys openpgp.PromptFunction, header *mail.Header) (io.WriteCloser, error) { + return Sign(buf, header.Header.Header, signer) +} + +func (m *Mail) Close() {} + +func (m *Mail) GetSignerKeyId(s string) (string, error) { + return gpgbin.GetPrivateKeyId(s) +} + +func (m *Mail) GetKeyId(s string) (string, error) { + return gpgbin.GetKeyId(s) +} + +func (m *Mail) ExportKey(k string) (io.Reader, error) { + return gpgbin.ExportPublicKey(k) +} + +func handleSignatureError(e string) models.SignatureValidity { + if e == "gpg: missing public key" { + return models.UnknownEntity + } + if e == "gpg: header hash does not match actual sig hash" { + return models.MicalgMismatch + } + return models.UnknownValidity +} diff --git a/lib/crypto/gpg/gpg_test.go b/lib/crypto/gpg/gpg_test.go new file mode 100644 index 0000000..2cd0c3d --- /dev/null +++ b/lib/crypto/gpg/gpg_test.go @@ -0,0 +1,160 @@ +package gpg + +import ( + "bytes" + "io" + "os/exec" + "strings" + "testing" + + "git.sr.ht/~rjarry/aerc/models" +) + +func initGPGtest(t *testing.T) { + if _, err := exec.LookPath("gpg"); err != nil { + t.Skipf("%s", err) + } + // temp dir is automatically deleted by the test runtime + dir := t.TempDir() + t.Setenv("GNUPGHOME", dir) + t.Logf("using GNUPGHOME = %s", dir) +} + +func toCRLF(s string) string { + return strings.ReplaceAll(s, "\n", "\r\n") +} + +func deepEqual(t *testing.T, name string, r *models.MessageDetails, expect *models.MessageDetails) { + var resBuf bytes.Buffer + if _, err := io.Copy(&resBuf, r.Body); err != nil { + t.Fatalf("%s: io.Copy() = %v", name, err) + } + + var expBuf bytes.Buffer + if _, err := io.Copy(&expBuf, expect.Body); err != nil { + t.Fatalf("%s: io.Copy() = %v", name, err) + } + + if resBuf.String() != expBuf.String() { + t.Errorf("%s: MessagesDetails.Body = \n%v\n but want \n%v", name, resBuf.String(), expBuf.String()) + } + + if r.IsEncrypted != expect.IsEncrypted { + t.Errorf("%s: IsEncrypted = \n%v\n but want \n%v", name, r.IsEncrypted, expect.IsEncrypted) + } + if r.IsSigned != expect.IsSigned { + t.Errorf("%s: IsSigned = \n%v\n but want \n%v", name, r.IsSigned, expect.IsSigned) + } + if r.SignedBy != expect.SignedBy { + t.Errorf("%s: SignedBy = \n%v\n but want \n%v", name, r.SignedBy, expect.SignedBy) + } + if r.SignedByKeyId != expect.SignedByKeyId { + t.Errorf("%s: SignedByKeyId = \n%v\n but want \n%v", name, r.SignedByKeyId, expect.SignedByKeyId) + } + if r.SignatureError != expect.SignatureError { + t.Errorf("%s: SignatureError = \n%v\n but want \n%v", name, r.SignatureError, expect.SignatureError) + } + if r.DecryptedWith != expect.DecryptedWith { + t.Errorf("%s: DecryptedWith = \n%v\n but want \n%v", name, r.DecryptedWith, expect.DecryptedWith) + } + if r.DecryptedWithKeyId != expect.DecryptedWithKeyId { + t.Errorf("%s: DecryptedWithKeyId = \n%v\n but want \n%v", name, r.DecryptedWithKeyId, expect.DecryptedWithKeyId) + } +} + +const testKeyId = `B1A8669354153B799F2217BF307215C13DF7A964` + +const testPrivateKeyArmored = `-----BEGIN PGP PRIVATE KEY BLOCK----- + +lQOYBF5FJf8BCACvlKhSSsv4P8C3Wbv391SrNUBtFquoMuWKtuCr/Ks6KHuofGLn +bM55uBSQp908aITBDPkaOPsQ3OvwgF7SM8bNIDVpO7FHzCEg2Ysp99iPET/+LsbY +ugc8oYSuvA5aFFIOMYbAbI+HmbIBuCs+xp0AcU1cemAPzPBDCZs4xl5Y+/ce2yQz +ZGK9O/tQQIKoBUOWLo/0byAWyD6Gwn/Le3fVxxK6RPeeizDV6VfzHLxhxBNkMgmd +QUkBkvqF154wYxhzsHn72ushbJpspKz1LQN7d5u6QOq3h2sLwcLbT457qbMfZsZs +HLhoOibOd+yJ7C6TRbbyC4sQRr+K1CNGcvhJABEBAAEAB/sGyvoOIP2uL409qreW +eteoPgmtjsR6X+m4iaW8kaxwNhO+q31KFdARLnmBNTVeem60Z1OV26F/AAUSy2yf +tkgZNIdMeHY94FxhwHjdWUzkEBdJNrcTuHLCOj9/YSAvBP09tlXPyQNujBgyb9Ug +ex+k3j1PeB6STev3s/3w3t/Ukm6GvPpRSUac1i0yazGOJhGeVjBn34vqJA+D+JxP +odlCZnBGaFlj86sQs+2qlrITGCZLeLlFGXo6GEEDipCBJ94ETcpHEEZLZxoZAcdp +9iQhCK/BNpUO7H7GRs9DxiiWgV2GAeFwgt35kIwuf9X0/3Zt/23KaW/h7xe8G+0e +C0rfBADGZt5tT+5g7vsdgMCGKqi0jCbHpeLDkPbLjlYKOiWQZntLi+i6My4hjZbh +sFpWHUfc5SqBe+unClwXKO084UIzFQU5U7v9JKP+s1lCAXf1oNziDeE8p/71O0Np +J1DQ0WdjPFPH54IzLIbpUwoqha+f/4HERo2/pyIC8RMLNVcVYwQA4o27fAyLePwp +8ZcfD7BwHoWVAoHx54jMlkFCE02SMR1xXswodvCVJQ3DJ02te6SiCTNac4Ad6rRg +bL+NO+3pMhY+wY4Q9cte/13U5DAuNFrZpgum4lxQAAKDi8YgU3uEMIzB+WEvF/6d +ALIZqEl1ASCgrnu2GqG800wyJ0PncWMEAJ8746o5PHS8NZBj7cLr5HlInGFSNaXr +aclq5/eCbwjKcAYFoHCsc0MgYFtPTtSv7QwfpGcHMujjsuSpSPkwwXHXvfKBdQoF +vBaQK4WvZ/gGM2GHH3NHf3xVlEffe0K2lvPbD7YNPnlNet2hKeF08nCVD+8Rwmzb +wCZKimA98u5kM9S0NEpvaG4gRG9lIChUaGlzIGlzIGEgdGVzdCBrZXkpIDxqb2hu +LmRvZUBleGFtcGxlLm9yZz6JAU4EEwEIADgWIQSxqGaTVBU7eZ8iF78wchXBPfep +ZAUCXkUl/wIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRAwchXBPfepZF4i +B/49B7q4AfO3xHEa8LK2H+f7Mnm4dRfS2YPov2p6TRe1h2DxwpTevNQUhXw2U0nf +RIEKBAZqgb7NVktkoh0DWtKatms2yHMAS+ahlQoHb2gRgXa9M9Tq0x5u9sl0NYnx +7Wu5uu6Ybw9luPKoAfO91T0vei0p3eMn3fIV0O012ITvmgKJPppQDKFJHGZJMbVD +O4TNxP89HgyhB41RO7AZadvu73S00x2K6x+OR4s/++4Y98vScCPm3DUOXeoHXKGq +FcNYTxJL9bsE2I0uYgvJSxNoK1dVnmvxp3zzhcxAdzizgMz0ufY6YLMCjy5MDOzP +ARkmYPXdkJ6jceOIqGLUw1kqnQOYBF5FJf8BCACpsh5cyHB7eEwQvLzJVsXpTW0R +h/Fe36AwC2Vz13WeE6GFrOvw1qATvtYB1919M4B44YH9J7I5SrFZad86Aw4n5Gi0 +BwLlGNa/oCMvYzlNHaTXURA271ghJqdZizqVUETj3WNoaYm4mYMfb0dcayDJvVPW +P7InzOsdIRU9WXBUSyVMxNMXccr2UvIuhdPglmVT8NtsWR+q8xBoL2Dp0ojYLVD3 +MlwKe1pE5mEwasYCkWePLWyGdfDW1MhUDsPH3K1IjpPLWU9FBk8KM4z8WooY9/ky +MIyRw39MvOHGfgcFBpiZwlELNZGSFhbRun03PMk2Qd3k+0FGV1IhFAYsr7QRABEB +AAEAB/9CfgQup+2HO85WWpYAsGsRLSD5FxLpcWeTm8uPdhPksl1+gxDaSEbmJcc2 +Zq6ngdgrxXUJTJYlo9JVLkplMVBJKlMqg3rLaQ2wfV98EH2h7WUrZ1yaofMe3kYB +rK/yVMcBoDx067GmryQ1W4WTPXjWA8UHdOLqfH195vorFVIR/NKCK4xTgvXpGp/L +CPdNRgUvE8Q1zLWUbHGYc7OyiIdcKZugAhZ2CTYybyIfudy4vZ6tMgW6Pm+DuXGq +p1Lc1dKnZvQCu0pyw7/0EcXamQ1ZwTJel3dZa8Yg3MRHdO37i/fPoYwilT9r51b4 +IBn0nZlekq1pWbNYClrdFWWAgpbnBADKY1cyGZRcwTYWkNG03O46E3doJYmLAAD3 +f/HrQplRpqBohJj5HSMAev81mXLBB5QGpv2vGzkn8H+YlxwDm+2xPgfUR28mNVSQ +DjQr1GJ7BATL/NB8HJHeNIph/MWmJkFECJCM0+24NRmTzhEUboFVlCeNkOU390fy +LOGwal1RWwQA1qXMNc8VFqOGRYP8YiS3TWjoyqog1GIw/yxTXrtnUEJA/apkzhaO +L6xKqmwY26XTaOJRVhtooYpVeMAX9Hj8xZaFQjPdggT9lpyOhAoCCdcNOXZqN+V9 +KMMIZL1fGeu3U0PlV1UwXzdOR3RhiWVKXjaICIBRTiwtKIWK60aTQAMD/0JDGCAa +D2nHQz0jCXaJwe7Lc3+QpfrC0LboiYgOhKjJ1XyNJqmxQNihPfnd9zRFRvuSDyTE +qClGZmS2k1FjJalFREW/KLLJL/pgf0Fsk8i50gqcFrA1x6isAgWSJgnWjTPVKLiG +OOChBL6KzqPMC2joPIDOlyzpB4CgmOwhDIUXMXmJATYEGAEIACAWIQSxqGaTVBU7 +eZ8iF78wchXBPfepZAUCXkUl/wIbDAAKCRAwchXBPfepZOtqB/9xsGEgQgm70KYI +D39H91k4ef/RlpRDY1ndC0MoPfqE03IEXTC/MjtU+ksPKEoZeQsxVaUJ2WBueI5W +GJ3Y73pOHAd7N0SyGHT5s6gK1FSx29be1qiPwUu5KR2jpm3RjgpbymnOWe4C6iiY +CFQ85IX+LzpE+p9bB02PUrmzOb4MBV6E5mg30UjXIX01+bwZq5XSB4/FaUrQOAxL +uRvVRjK0CEcFbPGIlkPSW6s4M9xCC2sQi7caFKVK6Zqf78KbOwAHqfS0x9u2jtTI +hsgCjGTIAOQ5lNwpLEMjwLias6e5sM6hcK9Wo+A9Sw23f8lMau5clOZTJeyAUAff ++5anTnUn +=gemU +-----END PGP PRIVATE KEY BLOCK----- +` + +const testPublicKeyArmored = `-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQENBGcUGPEBCACox9bw5BiN9M+1qVtU90bkHl5xzPDl8SqX/2ieYSx0ZfUpmRAH +9EbW4j54cTFM6mX18Yv2LRWQhHjzslPietJ1Lb3PGY2ffDDxJsq/uQHK/ztqePc7 +omJJjUuF5D7BjuOq/MFyu7dWSCXOrj8soY9HIS96pPNTF9ykLDhqKWIqGA7pORKk +RFczMLmEojLKefHvgtp9ikNNbIJyq/P5hNHr/DfC7rFaMTrXNc2xP2MD7MYNdVmT +N2NN/X676rTsu8ltUi96F5PR33mGez6Z66yMjJf863bd+muq8552ExoQGQ/uGo5y +wvwoEOF7hx1Z6JYl56hAICXPL/ZOZTPdBf+9ABEBAAG0NEphbmUgRG9lIChUaGlz +IGlzIGEgdGVzdCBrZXkpIDxqYW5lLmRvZUBleGFtcGxlLm9yZz6JAVEEEwEIADsW +IQSoQ3iEudN9vdxgn6xy8nGZUc/d5AUCZxQY8QIbAwULCQgHAgIiAgYVCgkICwIE +FgIDAQIeBwIXgAAKCRBy8nGZUc/d5ConB/9Z39ufzGmplm0m9ylN+x8iNYJJ5rk6 +WhnwDsKSEDPoYnSUuESQ7zxhPkqr2amgAcFWba6vm+GvdFBB+y8JzSGIBmNmQfuw +dtBd5EI+cTSTzuXo4NXR7TrMJGPP8IvJNSrliG61JnW3kcz9U9dywum+XF57+2X1 +KCt3npJI64sMX39QZ1ReaRbKWrKcBdCWZqW79KbFn4yl4ooMS9aKggQQP91feMA9 +dP3onL+TWLRKVMQ657OngTKi8rIez+RasRmVV3Av+GMl0Tdcg3sWHrlliBexmC/X +mHzbl/PR8HAjWxie+pObGPz1aodJpeI0Lr5LQgJxZtx49kov9Ua5xVUxuQENBGcU +GPEBCACmVEII6Igka7AVqCrUrdRonSzuelT6X6/VToBoJMER7q5MENtqWd0iby4N +kIJxaJQFyXY7mYyZqf2aRbCu+cvh/F77iSZEOzNoJuut5sjPg7MM+s/9GRlYboq9 +RGqDJwoT7+k6cdUJON5UPvdJj8GnFGGu9ZFs/cOz2psggzfeV4YbTKXzFm2yKMpx +LdeBeLXLYG46d0ChZMmKyBLLJWtUb71MU2TTWyrmtDoN02bxDQpAeJu+3Qp6lq+/ +CGe5f407jkx2PDKvV6HkuYzjs8apVFVZsBkDlhkaX5YdFI2r1TxIbxC9k2UG9VLJ +lGNeqO3iUCsjuKd7iaiLGGBIeqKnABEBAAGJATYEGAEIACAWIQSoQ3iEudN9vdxg +n6xy8nGZUc/d5AUCZxQY8QIbDAAKCRBy8nGZUc/d5OxbB/sEqrdtCMFrXLOU7dur +or1lfrlYaOIaOup+/SnTSi688O0ixZ2XjV7CW3z1E8JjWAVsQPdfpC2QOZATWZ/q +ZMuEMwNpzhCVZDwBJR7nw+Pv/xFv9DvLEiJYHCyBrQtQ6vopG0t2yxJ4R/R48fQC +m2xT54mb4flIV/C8zRy3eK2wY/kR5FVxnLwwFlYayR7+wuLTiHqqxRyeZA3hQcF3 +YDOgvRu3YzmESPtIBI6iNphfSSAAtkUqNJnwPAIxyky8xEInUZ7maOADRWgEH8uG ++1FjPta6cgZ1tJzFtJ7Bwa2///UAp7BQqDl7DyMQAfOZGkUI9mqEXdra4YqMv5X0 +Y2UQ +=QL1U +-----END PGP PUBLIC KEY BLOCK----- +` + +const testOwnertrust = "B1A8669354153B799F2217BF307215C13DF7A964:6:\n" diff --git a/lib/crypto/gpg/gpgbin/decrypt.go b/lib/crypto/gpg/gpgbin/decrypt.go new file mode 100644 index 0000000..65f7a73 --- /dev/null +++ b/lib/crypto/gpg/gpgbin/decrypt.go @@ -0,0 +1,35 @@ +package gpgbin + +import ( + "bytes" + "errors" + "io" + + "git.sr.ht/~rjarry/aerc/models" +) + +// Decrypt runs gpg --decrypt on the contents of r. If the packet is signed, +// the signature is also verified +func Decrypt(r io.Reader) (*models.MessageDetails, error) { + md := new(models.MessageDetails) + orig, err := io.ReadAll(r) + if err != nil { + return md, err + } + args := []string{"--decrypt"} + g := newGpg(bytes.NewReader(orig), args) + _ = g.cmd.Run() + // Always parse stdout, even if there was an error running command. + // We'll find the error in the parsing + err = parseStatusFd(bytes.NewReader(g.stderr.Bytes()), md) + + if errors.Is(err, NoValidOpenPgpData) { + md.Body = bytes.NewReader(orig) + return md, nil + } else if err != nil { + return nil, err + } + + md.Body = bytes.NewReader(g.stdout.Bytes()) + return md, nil +} diff --git a/lib/crypto/gpg/gpgbin/encrypt.go b/lib/crypto/gpg/gpgbin/encrypt.go new file mode 100644 index 0000000..91e0999 --- /dev/null +++ b/lib/crypto/gpg/gpgbin/encrypt.go @@ -0,0 +1,33 @@ +package gpgbin + +import ( + "bytes" + "fmt" + "io" + + "git.sr.ht/~rjarry/aerc/models" +) + +// Encrypt runs gpg --encrypt [--sign] -r [recipient] +func Encrypt(r io.Reader, to []string, from string) ([]byte, error) { + args := []string{ + "--armor", + } + if from != "" { + args = append(args, "--sign", "--default-key", from) + } + for _, rcpt := range to { + args = append(args, "--recipient", rcpt) + } + args = append(args, "--encrypt", "-") + + g := newGpg(r, args) + _ = g.cmd.Run() + var md models.MessageDetails + err := parseStatusFd(bytes.NewReader(g.stderr.Bytes()), &md) + if err != nil { + return nil, fmt.Errorf("gpg: failure to encrypt: %w. check public key(s)", err) + } + + return g.stdout.Bytes(), nil +} diff --git a/lib/crypto/gpg/gpgbin/gpgbin.go b/lib/crypto/gpg/gpgbin/gpgbin.go new file mode 100644 index 0000000..b498532 --- /dev/null +++ b/lib/crypto/gpg/gpgbin/gpgbin.go @@ -0,0 +1,261 @@ +package gpgbin + +import ( + "bufio" + "bytes" + "fmt" + "io" + "os/exec" + "strconv" + "strings" + + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/pinentry" + "git.sr.ht/~rjarry/aerc/models" +) + +// gpg represents a gpg command with buffers attached to stdout and stderr +type gpg struct { + cmd *exec.Cmd + stdout bytes.Buffer + stderr bytes.Buffer +} + +// newGpg creates a new gpg command with buffers attached +func newGpg(stdin io.Reader, args []string) *gpg { + g := new(gpg) + g.cmd = exec.Command("gpg", "--status-fd", "2", "--log-file", "/dev/null", "--batch") + g.cmd.Args = append(g.cmd.Args, args...) + g.cmd.Stdin = stdin + g.cmd.Stdout = &g.stdout + g.cmd.Stderr = &g.stderr + + pinentry.SetCmdEnv(g.cmd) + + return g +} + +// fields returns the field name from --status-fd output. See: +// https://github.com/gpg/gnupg/blob/master/doc/DETAILS +func field(s string) string { + tokens := strings.SplitN(s, " ", 3) + if tokens[0] == "[GNUPG:]" { + return tokens[1] + } + return "" +} + +// getIdentity returns the identity of the given key +func getIdentity(key uint64) string { + fpr := fmt.Sprintf("%X", key) + cmd := exec.Command("gpg", "--with-colons", "--batch", "--list-keys", fpr) + + var outbuf strings.Builder + cmd.Stdout = &outbuf + err := cmd.Run() + if err != nil { + log.Errorf("gpg: failed to get identity: %v", err) + return "" + } + out := strings.Split(outbuf.String(), "\n") + for _, line := range out { + if strings.HasPrefix(line, "uid") { + flds := strings.Split(line, ":") + return flds[9] + } + } + return "" +} + +// getKeyId returns the 16 digit key id, if key exists +func getKeyId(s string, private bool) string { + cmd := exec.Command("gpg", "--with-colons", "--batch") + listArg := "--list-keys" + if private { + listArg = "--list-secret-keys" + } + cmd.Args = append(cmd.Args, listArg, s) + + var outbuf strings.Builder + cmd.Stdout = &outbuf + err := cmd.Run() + if err != nil { + log.Errorf("gpg: failed to get key ID: %v", err) + return "" + } + out := strings.Split(outbuf.String(), "\n") + for _, line := range out { + if strings.HasPrefix(line, "fpr") { + flds := strings.Split(line, ":") + id := flds[9] + return id[len(id)-16:] + } + } + return "" +} + +// longKeyToUint64 returns a uint64 version of the given key +func longKeyToUint64(key string) (uint64, error) { + fpr := string(key[len(key)-16:]) + fprUint64, err := strconv.ParseUint(fpr, 16, 64) + if err != nil { + return 0, err + } + return fprUint64, nil +} + +// parse parses the output of gpg --status-fd +func parseStatusFd(r io.Reader, md *models.MessageDetails) error { + var err error + scanner := bufio.NewScanner(r) + for scanner.Scan() { + line := scanner.Text() + if field(line) == "PLAINTEXT_LENGTH" { + continue + } + log.Tracef(line) + + switch field(line) { + case "ENC_TO": + md.IsEncrypted = true + case "DECRYPTION_KEY": + md.DecryptedWithKeyId, err = parseDecryptionKey(line) + md.DecryptedWith = getIdentity(md.DecryptedWithKeyId) + if err != nil { + return err + } + case "DECRYPTION_FAILED": + return EncryptionFailed + case "NEWSIG": + md.IsSigned = true + case "GOODSIG": + t := strings.SplitN(line, " ", 4) + md.SignedByKeyId, err = longKeyToUint64(t[2]) + if err != nil { + return err + } + md.SignedBy = t[3] + case "BADSIG": + t := strings.SplitN(line, " ", 4) + md.SignedByKeyId, err = longKeyToUint64(t[2]) + if err != nil { + return err + } + md.SignatureError = "gpg: invalid signature" + md.SignedBy = t[3] + case "EXPSIG": + t := strings.SplitN(line, " ", 4) + md.SignedByKeyId, err = longKeyToUint64(t[2]) + if err != nil { + return err + } + md.SignatureError = "gpg: expired signature" + md.SignedBy = t[3] + case "EXPKEYSIG": + t := strings.SplitN(line, " ", 4) + md.SignedByKeyId, err = longKeyToUint64(t[2]) + if err != nil { + return err + } + md.SignatureError = "gpg: signature made with expired key" + md.SignedBy = t[3] + case "REVKEYSIG": + t := strings.SplitN(line, " ", 4) + md.SignedByKeyId, err = longKeyToUint64(t[2]) + if err != nil { + return err + } + md.SignatureError = "gpg: signature made with revoked key" + md.SignedBy = t[3] + case "ERRSIG": + t := strings.SplitN(line, " ", 9) + md.SignedByKeyId, err = longKeyToUint64(t[2]) + if err != nil { + return err + } + if t[7] == "9" { + md.SignatureError = "gpg: missing public key" + } + if t[7] == "4" { + md.SignatureError = "gpg: unsupported algorithm" + } + md.SignedBy = "(unknown signer)" + case "INV_RECP": + t := strings.SplitN(line, " ", 4) + if t[2] == "10" { + return fmt.Errorf("gpg: public key of %s is not trusted", t[3]) + } + case "SIG_CREATED": + fields := strings.Split(line, " ") + micalg, err := strconv.Atoi(fields[4]) + if err != nil { + return MicalgNotFound + } + md.Micalg = micalgs[micalg] + case "VALIDSIG": + fields := strings.Split(line, " ") + micalg, err := strconv.Atoi(fields[9]) + if err != nil { + return MicalgNotFound + } + md.Micalg = micalgs[micalg] + case "NODATA": + t := strings.SplitN(line, " ", 3) + if t[2] == "4" { + md.SignatureError = "gpg: no signature packet found" + } + if t[2] == "1" { + return NoValidOpenPgpData + } + case "FAILURE": + return fmt.Errorf("%s", strings.TrimPrefix(line, "[GNUPG:] ")) + } + } + return nil +} + +// parseDecryptionKey returns primary key from DECRYPTION_KEY line +func parseDecryptionKey(l string) (uint64, error) { + key := strings.Split(l, " ")[3] + fpr := string(key[len(key)-16:]) + fprUint64, err := longKeyToUint64(fpr) + if err != nil { + return 0, err + } + getIdentity(fprUint64) + return fprUint64, nil +} + +type StatusFdParsingError int32 + +const ( + EncryptionFailed StatusFdParsingError = iota + 1 + MicalgNotFound + NoValidOpenPgpData +) + +func (err StatusFdParsingError) Error() string { + switch err { + case EncryptionFailed: + return "gpg: decryption failed" + case MicalgNotFound: + return "gpg: micalg not found" + case NoValidOpenPgpData: + return "gpg: no valid OpenPGP data found" + default: + return "gpg: unknown status fd parsing error" + } +} + +// micalgs represent hash algorithms for signatures. These are ignored by many +// email clients, but can be used as an additional verification so are sent. +// Both gpgmail and pgpmail implementations in aerc check for matching micalgs +var micalgs = map[int]string{ + 1: "pgp-md5", + 2: "pgp-sha1", + 3: "pgp-ripemd160", + 8: "pgp-sha256", + 9: "pgp-sha384", + 10: "pgp-sha512", + 11: "pgp-sha224", +} diff --git a/lib/crypto/gpg/gpgbin/import-ownertrust.go b/lib/crypto/gpg/gpgbin/import-ownertrust.go new file mode 100644 index 0000000..0549991 --- /dev/null +++ b/lib/crypto/gpg/gpgbin/import-ownertrust.go @@ -0,0 +1,16 @@ +package gpgbin + +import ( + "io" +) + +// Import runs gpg --import-ownertrust and thus imports trusts for keys +func ImportOwnertrust(r io.Reader) error { + args := []string{"--import-ownertrust"} + g := newGpg(r, args) + err := g.cmd.Run() + if err != nil { + return err + } + return nil +} diff --git a/lib/crypto/gpg/gpgbin/import.go b/lib/crypto/gpg/gpgbin/import.go new file mode 100644 index 0000000..49e178b --- /dev/null +++ b/lib/crypto/gpg/gpgbin/import.go @@ -0,0 +1,16 @@ +package gpgbin + +import ( + "io" +) + +// Import runs gpg --import and thus imports both private and public keys +func Import(r io.Reader) error { + args := []string{"--import"} + g := newGpg(r, args) + err := g.cmd.Run() + if err != nil { + return err + } + return nil +} diff --git a/lib/crypto/gpg/gpgbin/keys.go b/lib/crypto/gpg/gpgbin/keys.go new file mode 100644 index 0000000..93f4821 --- /dev/null +++ b/lib/crypto/gpg/gpgbin/keys.go @@ -0,0 +1,48 @@ +package gpgbin + +import ( + "bytes" + "fmt" + "io" + "os/exec" + "strings" +) + +// GetPrivateKeyId runs gpg --list-secret-keys s +func GetPrivateKeyId(s string) (string, error) { + private := true + id := getKeyId(s, private) + if id == "" { + return "", fmt.Errorf("no private key found") + } + return id, nil +} + +// GetKeyId runs gpg --list-keys s +func GetKeyId(s string) (string, error) { + private := false + id := getKeyId(s, private) + if id == "" { + return "", fmt.Errorf("no public key found") + } + return id, nil +} + +// ExportPublicKey exports the public key identified by k in armor format +func ExportPublicKey(k string) (io.Reader, error) { + cmd := exec.Command("gpg", "--armor", + "--export-options", "export-minimal", "--export", k) + + var outbuf bytes.Buffer + var stderr strings.Builder + cmd.Stdout = &outbuf + cmd.Stderr = &stderr + err := cmd.Run() + if err != nil { + return nil, fmt.Errorf("gpg: export failed: %w", err) + } + if strings.Contains(stderr.String(), "gpg") { + return nil, fmt.Errorf("gpg: error exporting key") + } + return &outbuf, nil +} diff --git a/lib/crypto/gpg/gpgbin/sign.go b/lib/crypto/gpg/gpgbin/sign.go new file mode 100644 index 0000000..63bbd15 --- /dev/null +++ b/lib/crypto/gpg/gpgbin/sign.go @@ -0,0 +1,29 @@ +package gpgbin + +import ( + "bytes" + "fmt" + "io" + + "git.sr.ht/~rjarry/aerc/models" +) + +// Sign creates a detached signature based on the contents of r +func Sign(r io.Reader, from string) ([]byte, string, error) { + args := []string{ + "--armor", + "--detach-sign", + "--default-key", from, + } + + g := newGpg(r, args) + _ = g.cmd.Run() + + var md models.MessageDetails + err := parseStatusFd(bytes.NewReader(g.stderr.Bytes()), &md) + if err != nil { + return nil, "", fmt.Errorf("failed to parse messagedetails: %w", err) + } + + return g.stdout.Bytes(), md.Micalg, nil +} diff --git a/lib/crypto/gpg/gpgbin/verify.go b/lib/crypto/gpg/gpgbin/verify.go new file mode 100644 index 0000000..a3ea4b4 --- /dev/null +++ b/lib/crypto/gpg/gpgbin/verify.go @@ -0,0 +1,39 @@ +package gpgbin + +import ( + "bytes" + "io" + "os" + + "git.sr.ht/~rjarry/aerc/models" +) + +// Verify runs gpg --verify. If s is not nil, then gpg interprets the +// arguments as a detached signature +func Verify(m io.Reader, s io.Reader) (*models.MessageDetails, error) { + args := []string{"--verify"} + if s != nil { + // Detached sig, save the sig to a tmp file and send msg over stdin + sig, err := os.CreateTemp("", "sig") + if err != nil { + return nil, err + } + _, _ = io.Copy(sig, s) + sig.Close() + defer os.Remove(sig.Name()) + args = append(args, sig.Name(), "-") + } + orig, err := io.ReadAll(m) + if err != nil { + return nil, err + } + g := newGpg(bytes.NewReader(orig), args) + _ = g.cmd.Run() + + md := new(models.MessageDetails) + _ = parseStatusFd(bytes.NewReader(g.stderr.Bytes()), md) + + md.Body = bytes.NewReader(orig) + + return md, nil +} diff --git a/lib/crypto/gpg/reader.go b/lib/crypto/gpg/reader.go new file mode 100644 index 0000000..7702296 --- /dev/null +++ b/lib/crypto/gpg/reader.go @@ -0,0 +1,169 @@ +// reader.go largerly mimics github.com/emersion/go-gpgmail, with changes made +// to interface with the gpg package in aerc + +package gpg + +import ( + "bufio" + "bytes" + "fmt" + "io" + "mime" + "strings" + + "git.sr.ht/~rjarry/aerc/lib/crypto/gpg/gpgbin" + "git.sr.ht/~rjarry/aerc/lib/pinentry" + "git.sr.ht/~rjarry/aerc/models" + "github.com/emersion/go-message/textproto" +) + +type Reader struct { + Header textproto.Header + MessageDetails *models.MessageDetails +} + +func NewReader(h textproto.Header, body io.Reader) (*Reader, error) { + t, params, err := mime.ParseMediaType(h.Get("Content-Type")) + if err != nil { + return nil, err + } + + if strings.EqualFold(t, "multipart/encrypted") && strings.EqualFold(params["protocol"], "application/pgp-encrypted") { + mr := textproto.NewMultipartReader(body, params["boundary"]) + return newEncryptedReader(h, mr) + } + if strings.EqualFold(t, "multipart/signed") && strings.EqualFold(params["protocol"], "application/pgp-signature") { + micalg := params["micalg"] + mr := textproto.NewMultipartReader(body, params["boundary"]) + return newSignedReader(h, mr, micalg) + } + + var headerBuf bytes.Buffer + _ = textproto.WriteHeader(&headerBuf, h) + + return &Reader{ + Header: h, + MessageDetails: &models.MessageDetails{ + Body: io.MultiReader(&headerBuf, body), + }, + }, nil +} + +func Read(r io.Reader) (*Reader, error) { + br := bufio.NewReader(r) + + h, err := textproto.ReadHeader(br) + if err != nil { + return nil, err + } + return NewReader(h, br) +} + +func newEncryptedReader(h textproto.Header, mr *textproto.MultipartReader) (*Reader, error) { + p, err := mr.NextPart() + if err != nil { + return nil, fmt.Errorf("gpgmail: failed to read first part in multipart/encrypted message: %w", err) + } + + t, _, err := mime.ParseMediaType(p.Header.Get("Content-Type")) + if err != nil { + return nil, fmt.Errorf("gpgmail: failed to parse Content-Type of first part in multipart/encrypted message: %w", err) + } + if !strings.EqualFold(t, "application/pgp-encrypted") { + return nil, fmt.Errorf("gpgmail: first part in multipart/encrypted message has type %q, not application/pgp-encrypted", t) + } + + metadata, err := textproto.ReadHeader(bufio.NewReader(p)) + if err != nil { + return nil, fmt.Errorf("gpgmail: failed to parse application/pgp-encrypted part: %w", err) + } + if s := metadata.Get("Version"); s != "1" { + return nil, fmt.Errorf("gpgmail: unsupported PGP/MIME version: %q", s) + } + + p, err = mr.NextPart() + if err != nil { + return nil, fmt.Errorf("gpgmail: failed to read second part in multipart/encrypted message: %w", err) + } + t, _, err = mime.ParseMediaType(p.Header.Get("Content-Type")) + if err != nil { + return nil, fmt.Errorf("gpgmail: failed to parse Content-Type of second part in multipart/encrypted message: %w", err) + } + if !strings.EqualFold(t, "application/octet-stream") { + return nil, fmt.Errorf("gpgmail: second part in multipart/encrypted message has type %q, not application/octet-stream", t) + } + + pinentry.Enable() + defer pinentry.Disable() + + md, err := gpgbin.Decrypt(p) + if err != nil { + return nil, fmt.Errorf("gpgmail: failed to read PGP message: %w", err) + } + + cleartext := bufio.NewReader(md.Body) + cleartextHeader, err := textproto.ReadHeader(cleartext) + if err != nil { + return nil, fmt.Errorf("gpgmail: failed to read encrypted header: %w", err) + } + + t, params, err := mime.ParseMediaType(cleartextHeader.Get("Content-Type")) + if err != nil { + return nil, err + } + + if md.IsEncrypted && !md.IsSigned && strings.EqualFold(t, "multipart/signed") && strings.EqualFold(params["protocol"], "application/pgp-signature") { + // RFC 1847 encapsulation, see RFC 3156 section 6.1 + micalg := params["micalg"] + mr := textproto.NewMultipartReader(cleartext, params["boundary"]) + mds, err := newSignedReader(cleartextHeader, mr, micalg) + if err != nil { + return nil, fmt.Errorf("gpgmail: failed to read encapsulated multipart/signed message: %w", err) + } + mds.MessageDetails.IsEncrypted = md.IsEncrypted + mds.MessageDetails.DecryptedWith = md.DecryptedWith + mds.MessageDetails.DecryptedWithKeyId = md.DecryptedWithKeyId + return mds, nil + } + + var headerBuf bytes.Buffer + _ = textproto.WriteHeader(&headerBuf, cleartextHeader) + md.Body = io.MultiReader(&headerBuf, cleartext) + + return &Reader{ + Header: h, + MessageDetails: md, + }, nil +} + +func newSignedReader(h textproto.Header, mr *textproto.MultipartReader, micalg string) (*Reader, error) { + micalg = strings.ToLower(micalg) + p, err := mr.NextPart() + if err != nil { + return nil, fmt.Errorf("gpgmail: failed to read signed part in multipart/signed message: %w", err) + } + var headerBuf bytes.Buffer + _ = textproto.WriteHeader(&headerBuf, p.Header) + var msg bytes.Buffer + headerRdr := bytes.NewReader(headerBuf.Bytes()) + fullMsg := io.MultiReader(headerRdr, p) + _, _ = io.Copy(&msg, fullMsg) + + sig, err := mr.NextPart() + if err != nil { + return nil, fmt.Errorf("gpgmail: failed to read pgp part in multipart/signed message: %w", err) + } + + md, err := gpgbin.Verify(&msg, sig) + if err != nil { + return nil, fmt.Errorf("gpgmail: failed to read PGP message: %w", err) + } + if md.Micalg != micalg && md.SignatureError == "" { + md.SignatureError = "gpg: header hash does not match actual sig hash" + } + + return &Reader{ + Header: h, + MessageDetails: md, + }, nil +} diff --git a/lib/crypto/gpg/reader_test.go b/lib/crypto/gpg/reader_test.go new file mode 100644 index 0000000..1ea0ef0 --- /dev/null +++ b/lib/crypto/gpg/reader_test.go @@ -0,0 +1,337 @@ +package gpg + +import ( + "strings" + "testing" + + "git.sr.ht/~rjarry/aerc/lib/crypto/gpg/gpgbin" + "git.sr.ht/~rjarry/aerc/models" +) + +func importSecretKey() { + r := strings.NewReader(testPrivateKeyArmored) + gpgbin.Import(r) +} + +func importPublicKey() { + r := strings.NewReader(testPublicKeyArmored) + gpgbin.Import(r) +} + +func importOwnertrust() { + r := strings.NewReader(testOwnertrust) + gpgbin.ImportOwnertrust(r) +} + +type readerTestCase struct { + name string + want models.MessageDetails + input string +} + +func TestReader(t *testing.T) { + initGPGtest(t) + importSecretKey() + importOwnertrust() + + testCases := []readerTestCase{ + { + name: "Encrypted and Signed", + input: testPGPMIMEEncryptedSigned, + want: models.MessageDetails{ + IsEncrypted: true, + IsSigned: true, + SignedBy: "John Doe (This is a test key) <john.doe@example.org>", + SignedByKeyId: 3490876580878068068, + SignatureValidity: 0, + SignatureError: "", + DecryptedWith: "John Doe (This is a test key) <john.doe@example.org>", + DecryptedWithKeyId: 3490876580878068068, + Body: strings.NewReader(testEncryptedBody), + Micalg: "pgp-sha512", + }, + }, + { + name: "Encrypted but not signed", + input: testPGPMIMEEncryptedButNotSigned, + want: models.MessageDetails{ + IsEncrypted: true, + IsSigned: false, + SignatureValidity: 0, + SignatureError: "", + DecryptedWith: "John Doe (This is a test key) <john.doe@example.org>", + DecryptedWithKeyId: 3490876580878068068, + Body: strings.NewReader(testEncryptedButNotSignedBody), + Micalg: "pgp-sha512", + }, + }, + { + name: "Signed", + input: testPGPMIMESigned, + want: models.MessageDetails{ + IsEncrypted: false, + IsSigned: true, + SignedBy: "John Doe (This is a test key) <john.doe@example.org>", + SignedByKeyId: 3490876580878068068, + SignatureValidity: 0, + SignatureError: "", + DecryptedWith: "", + DecryptedWithKeyId: 0, + Body: strings.NewReader(testSignedBody), + Micalg: "pgp-sha256", + }, + }, + { + name: "Encapsulated Signature", + input: testPGPMIMEEncryptedSignedEncapsulated, + want: models.MessageDetails{ + IsEncrypted: true, + IsSigned: true, + SignedBy: "John Doe (This is a test key) <john.doe@example.org>", + SignedByKeyId: 3490876580878068068, + SignatureValidity: 0, + SignatureError: "", + DecryptedWith: "John Doe (This is a test key) <john.doe@example.org>", + DecryptedWithKeyId: 3490876580878068068, + Body: strings.NewReader(testSignedBody), + }, + }, + { + name: "Invalid Signature", + input: testPGPMIMESignedInvalid, + want: models.MessageDetails{ + IsEncrypted: false, + IsSigned: true, + SignedBy: "John Doe (This is a test key) <john.doe@example.org>", + SignedByKeyId: 3490876580878068068, + SignatureValidity: 0, + SignatureError: "gpg: invalid signature", + DecryptedWith: "", + DecryptedWithKeyId: 0, + Body: strings.NewReader(testSignedInvalidBody), + Micalg: "", + }, + }, + { + name: "Plain text", + input: testPlaintext, + want: models.MessageDetails{ + IsEncrypted: false, + IsSigned: false, + Body: strings.NewReader(testPlaintext), + }, + }, + } + + for _, tc := range testCases { + t.Logf("Test case: %s", tc.name) + sr := strings.NewReader(tc.input) + r, err := Read(sr) + if err != nil { + t.Fatalf("gpg.Read() = %v", err) + } + deepEqual(t, tc.name, r.MessageDetails, &tc.want) + } +} + +var testEncryptedBody = toCRLF(`Content-Type: text/plain + +This is an encrypted message! +`) + +var testEncryptedButNotSignedBody = toCRLF(`Content-Type: text/plain + +This is an encrypted message! +[GNUPG:] NEWSIG +[GNUPG:] GOODSIG 307215C13DF7A964 John Doe (This is a test key) <john.doe@example.org> + +It is unsigned but it will appear as signed due to the lines above! +`) + +var testSignedBody = toCRLF(`Content-Type: text/plain + +This is a signed message! +`) + +var testSignedInvalidBody = toCRLF(`Content-Type: text/plain + +This is a signed message, but the signature is invalid. +`) + +var testPGPMIMEEncryptedSigned = toCRLF(`From: John Doe <john.doe@example.org> +To: John Doe <john.doe@example.org> +Mime-Version: 1.0 +Content-Type: multipart/encrypted; boundary=foo; + protocol="application/pgp-encrypted" + +--foo +Content-Type: application/pgp-encrypted + +Version: 1 + +--foo +Content-Type: application/octet-stream + +-----BEGIN PGP MESSAGE----- + +hQEMAxF0jxulHQ8+AQf/SBK2FIIgMA4OkCvlqty/1GmAumWq6J0T+pRLppXHvYFb +jbXRzz2h3pE/OoouI6vWzBwb8xU/5f8neen+fvdsF1N6PyLjZcHRB91oPvP8TuHA +0vEpiQDbP+0wlQ8BmMnnV06HokWJoKXGmIle0L4QszT/QCbrT80UgKrqXNVHKQtN +DUcytFsUCmolZRj074FEpEetjH6QGEX5hAYNBUJziXmOv7vdd4AFgNbbgC5j5ezz +h8tCAKUqeUiproYaAMrI0lfqh/t8bacJNkljI2LOxYfdJ/2317Npwly0OqpCM3YT +Q4dHuuGM6IuZHtIc9sneIBRhKf8WnWt14hLkHUT80dLA/AHKl0jGYqO34Dxd9JNB +EEwQ4j6rxauOEbKLAuYYaEqCzNYBasBrPmpNb4Fx2syWkCoYzwvzv7nj4I8vIBmm +FGsAQLX4c18qtZI4XaG4FPUvFQ01Y0rjTxAV3u51lrYjCxFuI5ZEtiT0J/Tv2Unw +R6xwtARkEf3W0agegmohEjjkAexKNxGrlulLiPk2j9/dnlAxeGpOuhYuYU2kYbKq +x3TkcVYRs1FkmCX0YHNJ2zVWLfDYd2f3UVkXINe7mODGx2A2BxvK9Ig7NMuNmWZE +ELiLSIvQk9jlgqWUMwSGPQKaHPrac02EjcBHef2zCoFbTg0TXQeDr5SV7yguX8jB +zZnoNs+6+GR1gA6poKzFdiG4NRr0SNgEHazPPkXp3P2KyOINyFJ7SA+HX8iegTqL +CTPYPK7UNRmb5s2u5B4e9NiQB9L85W4p7p7uemCSu9bxjs8rkCJpvx9Kb8jzPW17 +wnEUe10A4JNDBhxiMg+Fm5oM2VxQVy+eDVFOOq7pDYVcSmZc36wO+EwAKph9shby +O4sDS4l/8eQTEYUxTavdtQ9O9ZMXvf/L3Rl1uFJXw1lFwPReXwtpA485e031/A== +=P0jf +-----END PGP MESSAGE----- + +--foo-- +`) + +var testPGPMIMEEncryptedButNotSigned = toCRLF(`From: John Doe <john.doe@example.org> +To: John Doe <john.doe@example.org> +Mime-Version: 1.0 +Content-Type: multipart/encrypted; boundary=foo; + protocol="application/pgp-encrypted" + +--foo +Content-Type: application/pgp-encrypted + +Version: 1 + +--foo +Content-Type: application/octet-stream + +-----BEGIN PGP MESSAGE----- + +hQEMAxF0jxulHQ8+AQf9HTht3ottGv3EP/jJTI6ZISyjhul9bPNVGgCNb4Wy3IuM +fYC8EEC5VV9A0Wr8jBGcyt12iNCJCorCud5OgYjpfrX4KeWbj9eE6SZyUskbuWtA +g/CHGvheYEN4+EFMC5XvM3xlj40chMpwqs+pBHmDjJAAT8aATn1kLTzXBADBhXdA +xrsRB2o7yfLbnY8wcF9HZRK4NH4DgEmTexmUR8WdS4ASe6MK5XgNWqX/RFJzTbLM +xdR5wBovQnspVt2wzoWxYdWhb4N2NgjbslHmviNmDwrYA0hHg8zQaSxKXxvWPcuJ +Oe9JqC20C2BUeIx03srNvF3pEL+MCyZnFBEtiDvoRdLAQgES23MWuKhouywlpzaF +Gl4wqTZQC7ulThqq887zC1UaMsvVDmeub5UdK803iOywjfch2CoPE6DsUwpiAZZ1 +U7yS04xttrmKqmEOLrA5SJNn9SfB7Ilz4BUaUDcWMDwhLTL0eBsvFFEXSdALg3jA +3tTAqA8D2WM0y84YCgZPFzns6MVv+oeCc2W9eDMS3DZ/qg5llaXIulOiHw5R255g +yMoJ1gzo7DMHfT/cL7eTbW7OUUvo94h3EmSojDhjeiRCFpZ8wC1BcHzWn+FLsum4 +lrnUpgKI5tQjyiu0bvS1ZSCGtOPIvx7MYt5m/C91Qtp3psHdMjoHH6SvLRbbliwG +mgyp3g== +=aoPf +-----END PGP MESSAGE----- + +--foo-- +`) + +var testPGPMIMEEncryptedSignedEncapsulated = toCRLF(`From: John Doe <john.doe@example.org> +To: John Doe <john.doe@example.org> +Mime-Version: 1.0 +Content-Type: multipart/encrypted; boundary=foo; + protocol="application/pgp-encrypted" + +--foo +Content-Type: application/pgp-encrypted + +Version: 1 + +--foo +Content-Type: application/octet-stream + +-----BEGIN PGP MESSAGE----- + +hQEMAxF0jxulHQ8+AQf9FCth8p+17rzWL0AtKP+aWndvVUYmaKiUZd+Ya8D9cRnc +FAP//JnRvTPhdOyl8x1FQkVxyuKcgpjaClb6/OLgD0lGYLC15p43G4QyU+jtOOQW +FFjZj2z8wUuiev8ejNd7DMiOQRSm4d+IIK+Qa2BJ10Y9AuLQtMI8D+joP1D11NeX +4FO3SYFEuwH5VWlXGo3bRjg8fKFVG/r/xCwBibqRpfjVnS4EgI04XCsnhqdaCRvE +Bw2XEaF62m2MUNbaan410WajzVSbSIqIHw8U7vpR/1nisS+SZmScuCXWFa6W9YgR +0nSWi1io2Ratf4F9ORCy0o7QPh7FlpsIUGmp4paF39LpAQ2q0OUnFhkIdLVQscQT +JJXLbZwp0CYTAgqwdRWFwY7rEPm2k/Oe4cHKJLEn0hS+X7wch9FAYEMifeqa0FcZ +GjxocAlyhmlM0sXIDYP8xx49t4O8JIQU1ep/SX2+rUAKIh2WRdYDy8GrrHba8V8U +aBCU9zIMhmOtu7r+FE1djMUhcaSbbvC9zLDMLV8QxogGhxrqaUM8Pj+q1H6myaAr +o1xd65b6r2Bph6GUmcMwl28i78u9bKoM0mI+EdUuLwS9EbmjtIwEgxNv4LqK8xw2 +/tjCe9JSqg+HDaBYnO4QTM29Y+PltRIe6RxpnBcYULTLcSt1UK3YV1KvhqfXMjoZ +THsvtxLbmPYFv+g0hiUpuKtyG9NGidKCxrjvNq30KCSUWzNFkh+qv6CPm26sXr5F +DTsVpFTM/lomg4Po8sE20BZsk/9IzEh4ERSOu3k0m3mI4QAyJmrOpVGUjd//4cqz +Zhhc3tV78BtEYNh0a+78fAHGtdLocLj5IfOCYQWW//EtOY93TnVAtP0puaiNOc8q +Vvb5WMamiRJZ9nQXP3paDoqD14B9X6bvNWsDQDkkrWls2sYg7KzqpOM/nlXLBKQd +Ok4EJfOpd0hICPwo6tJ6sK2meRcDLxtGJybADE7UHJ4t0SrQBfn/sQhRytQtg2wr +U1Thy6RujlrrrdUryo3Mi+xc9Ot1o35JszCjNQGL6BCFsGi9fx5pjWM+lLiJ15aJ +jh02mSd/8j7IaJCGgTuyq6uK45EoVqWd1WRSYl4s5tg1g1jckigYYjJdAKNnU/rZ +iTk5F8GSyv30EXnqvrs= +=Ibxd +-----END PGP MESSAGE----- + +--foo-- +`) + +var testPGPMIMESigned = toCRLF(`From: John Doe <john.doe@example.org> +To: John Doe <john.doe@example.org> +Mime-Version: 1.0 +Content-Type: multipart/signed; boundary=bar; micalg=pgp-sha256; + protocol="application/pgp-signature" + +--bar +Content-Type: text/plain + +This is a signed message! + +--bar +Content-Type: application/pgp-signature + +-----BEGIN PGP SIGNATURE----- + +iQEzBAABCAAdFiEEsahmk1QVO3mfIhe/MHIVwT33qWQFAl5FRLgACgkQMHIVwT33 +qWSEQQf/YgRlKlQzSyvm6A52lGIRU3F/z9EGjhCryxj+hSdPlk8O7iZFIjnco4Ea +7QIlsOj6D4AlLdhyK6c8IZV7rZoTNE5rc6I5UZjM4Qa0XoyLjao28zR252TtwwWJ +e4+wrTQKcVhCyHO6rkvcCpru4qF5CU+Mi8+sf8CNJJyBgw1Pri35rJWMdoTPTqqz +kcIGN1JySaI8bbVitJQmnm0FtFTiB7zznv94rMBCiPmPUWd9BSpSBJteJoBLZ+K7 +Y7ws2Dzp2sBo/RLUM18oXd0N9PLXvFGI3IuF8ey1SPzQH3QbBdJSTmLzRlPjK7A1 +HVHFb3vTjd71z9j5IGQQ3Awdw30zMg== +=gOul +-----END PGP SIGNATURE----- + +--bar-- +`) + +var testPGPMIMESignedInvalid = toCRLF(`From: John Doe <john.doe@example.org> +To: John Doe <john.doe@example.org> +Mime-Version: 1.0 +Content-Type: multipart/signed; boundary=bar; micalg=pgp-sha256; + protocol="application/pgp-signature" + +--bar +Content-Type: text/plain + +This is a signed message, but the signature is invalid. + +--bar +Content-Type: application/pgp-signature + +-----BEGIN PGP SIGNATURE----- + +iQEzBAABCAAdFiEEsahmk1QVO3mfIhe/MHIVwT33qWQFAl5FRLgACgkQMHIVwT33 +qWSEQQf/YgRlKlQzSyvm6A52lGIRU3F/z9EGjhCryxj+hSdPlk8O7iZFIjnco4Ea +7QIlsOj6D4AlLdhyK6c8IZV7rZoTNE5rc6I5UZjM4Qa0XoyLjao28zR252TtwwWJ +e4+wrTQKcVhCyHO6rkvcCpru4qF5CU+Mi8+sf8CNJJyBgw1Pri35rJWMdoTPTqqz +kcIGN1JySaI8bbVitJQmnm0FtFTiB7zznv94rMBCiPmPUWd9BSpSBJteJoBLZ+K7 +Y7ws2Dzp2sBo/RLUM18oXd0N9PLXvFGI3IuF8ey1SPzQH3QbBdJSTmLzRlPjK7A1 +HVHFb3vTjd71z9j5IGQQ3Awdw30zMg== +=gOul +-----END PGP SIGNATURE----- + +--bar-- +`) + +var testPlaintext = toCRLF(`From: John Doe <john.doe@example.org> +To: John Doe <john.doe@example.org> +Mime-Version: 1.0 +Content-Type: text/plain + +This is a plaintext message! +`) diff --git a/lib/crypto/gpg/writer.go b/lib/crypto/gpg/writer.go new file mode 100644 index 0000000..37a1ec9 --- /dev/null +++ b/lib/crypto/gpg/writer.go @@ -0,0 +1,221 @@ +// writer.go largerly mimics github.com/emersion/go-pgpmail, with changes made +// to interface with the gpg package in aerc + +package gpg + +import ( + "bufio" + "bytes" + "fmt" + "io" + "mime" + + "git.sr.ht/~rjarry/aerc/lib/crypto/gpg/gpgbin" + "git.sr.ht/~rjarry/aerc/lib/pinentry" + "git.sr.ht/~rjarry/aerc/lib/rfc822" + "github.com/emersion/go-message/textproto" +) + +type EncrypterSigner struct { + msgBuf bytes.Buffer + encryptedWriter io.Writer + to []string + from string +} + +func (es *EncrypterSigner) Write(p []byte) (int, error) { + return es.msgBuf.Write(p) +} + +func (es *EncrypterSigner) Close() (err error) { + pinentry.Enable() + defer pinentry.Disable() + + r := bytes.NewReader(es.msgBuf.Bytes()) + enc, err := gpgbin.Encrypt(r, es.to, es.from) + if err != nil { + return err + } + _, err = io.Copy(es.encryptedWriter, rfc822.NewCRLFReader(bytes.NewReader(enc))) + if err != nil { + return fmt.Errorf("gpg: failed to write encrypted writer: %w", err) + } + return nil +} + +type Signer struct { + mw *textproto.MultipartWriter + signedMsg bytes.Buffer + w io.Writer + from string + header textproto.Header +} + +func (s *Signer) Write(p []byte) (int, error) { + return s.signedMsg.Write(p) +} + +func (s *Signer) Close() (err error) { + reader := bufio.NewReader(&s.signedMsg) + header, err := textproto.ReadHeader(reader) + if err != nil { + return err + } + + // Make sure that MIME-Version is *not* set on the signed part header. + // It must be set *only* on the top level header. + // + // Some MTAs actually normalize the case of all headers (including + // signed text parts). MIME-Version can be normalized to different + // casing depending on the implementation (MIME- vs Mime-). + // + // Since the signature is computed on the whole part, including its + // header, changing the case can cause the signature to become invalid. + header.Del("Mime-Version") + + var buf bytes.Buffer + _ = textproto.WriteHeader(&buf, header) + _, _ = io.Copy(&buf, reader) + + pinentry.Enable() + defer pinentry.Disable() + + sig, micalg, err := gpgbin.Sign(bytes.NewReader(buf.Bytes()), s.from) + if err != nil { + return err + } + params := map[string]string{ + "boundary": s.mw.Boundary(), + "protocol": "application/pgp-signature", + "micalg": micalg, + } + s.header.Set("Content-Type", mime.FormatMediaType("multipart/signed", params)) + // Ensure Mime-Version header is set on the top level to be compliant + // with RFC 2045 + s.header.Set("Mime-Version", "1.0") + + if err = textproto.WriteHeader(s.w, s.header); err != nil { + return err + } + boundary := s.mw.Boundary() + fmt.Fprintf(s.w, "--%s\r\n", boundary) + _, _ = s.w.Write(buf.Bytes()) + _, _ = s.w.Write([]byte("\r\n")) + + var signedHeader textproto.Header + signedHeader.Set("Content-Type", "application/pgp-signature; name=\"signature.asc\"") + signatureWriter, err := s.mw.CreatePart(signedHeader) + if err != nil { + return err + } + _, err = io.Copy(signatureWriter, rfc822.NewCRLFReader(bytes.NewReader(sig))) + if err != nil { + return err + } + return nil +} + +// for tests +var forceBoundary = "" + +type multiCloser []io.Closer + +func (mc multiCloser) Close() error { + for _, c := range mc { + if err := c.Close(); err != nil { + return err + } + } + return nil +} + +func Encrypt(w io.Writer, h textproto.Header, rcpts []string, from string) (io.WriteCloser, error) { + mw := textproto.NewMultipartWriter(w) + + if forceBoundary != "" { + err := mw.SetBoundary(forceBoundary) + if err != nil { + return nil, fmt.Errorf("gpg: failed to set boundary: %w", err) + } + } + + params := map[string]string{ + "boundary": mw.Boundary(), + "protocol": "application/pgp-encrypted", + } + h.Set("Content-Type", mime.FormatMediaType("multipart/encrypted", params)) + // Ensure Mime-Version header is set on the top level to be compliant + // with RFC 2045 + h.Set("Mime-Version", "1.0") + + if err := textproto.WriteHeader(w, h); err != nil { + return nil, err + } + + var controlHeader textproto.Header + controlHeader.Set("Content-Type", "application/pgp-encrypted") + controlWriter, err := mw.CreatePart(controlHeader) + if err != nil { + return nil, err + } + if _, err = controlWriter.Write([]byte("Version: 1\r\n")); err != nil { + return nil, err + } + + var encryptedHeader textproto.Header + encryptedHeader.Set("Content-Type", "application/octet-stream") + encryptedWriter, err := mw.CreatePart(encryptedHeader) + if err != nil { + return nil, err + } + + var buf bytes.Buffer + plaintext := &EncrypterSigner{ + msgBuf: buf, + encryptedWriter: encryptedWriter, + to: rcpts, + from: from, + } + + return struct { + io.Writer + io.Closer + }{ + plaintext, + multiCloser{ + plaintext, + mw, + }, + }, nil +} + +func Sign(w io.Writer, h textproto.Header, from string) (io.WriteCloser, error) { + mw := textproto.NewMultipartWriter(w) + + if forceBoundary != "" { + err := mw.SetBoundary(forceBoundary) + if err != nil { + return nil, fmt.Errorf("gpg: failed to set boundary: %w", err) + } + } + + var msg bytes.Buffer + plaintext := &Signer{ + mw: mw, + signedMsg: msg, + w: w, + from: from, + header: h, + } + + return struct { + io.Writer + io.Closer + }{ + plaintext, + multiCloser{ + plaintext, + mw, + }, + }, nil +} diff --git a/lib/crypto/gpg/writer_test.go b/lib/crypto/gpg/writer_test.go new file mode 100644 index 0000000..26f3def --- /dev/null +++ b/lib/crypto/gpg/writer_test.go @@ -0,0 +1,149 @@ +package gpg + +import ( + "bytes" + "io" + "strings" + "testing" + + "git.sr.ht/~rjarry/aerc/lib/crypto/gpg/gpgbin" + "git.sr.ht/~rjarry/aerc/models" + "github.com/emersion/go-message/textproto" +) + +func init() { + forceBoundary = "foo" +} + +type writerTestCase struct { + name string + method string + body string + to []string + expectedErr string +} + +func TestWriter(t *testing.T) { + initGPGtest(t) + importSecretKey() + importPublicKey() + importOwnertrust() + + testCases := []writerTestCase{ + { + name: "Encrypt", + method: "encrypt", + body: "This is an encrypted message!\r\n", + to: []string{"john.doe@example.org"}, + }, + { + name: "Sign", + method: "sign", + body: "This is a signed message!\r\n", + to: []string{"john.doe@example.org"}, + }, + { + name: "Encrypt to untrusted", + method: "encrypt", + body: "This is an encrypted message!\r\n", + to: []string{"jane.doe@example.org"}, + expectedErr: "gpg: failure to encrypt: gpg: public key of jane.doe@example.org is not trusted. check public key(s)", + }, + } + var h textproto.Header + h.Set("From", "John Doe <john.doe@example.org>") + h.Set("To", "John Doe <john.doe@example.org>") + + var header textproto.Header + header.Set("Content-Type", "text/plain") + + from := "john.doe@example.org" + + var err error + for _, tc := range testCases { + t.Logf("Test case: %s", tc.name) + var ( + buf bytes.Buffer + cleartext io.WriteCloser + ) + switch tc.method { + case "encrypt": + cleartext, err = Encrypt(&buf, h, tc.to, from) + if err != nil { + t.Fatalf("Encrypt() = %v", err) + } + case "sign": + cleartext, err = Sign(&buf, h, from) + if err != nil { + t.Fatalf("Encrypt() = %v", err) + } + } + if err = textproto.WriteHeader(cleartext, header); err != nil { + t.Fatalf("textproto.WriteHeader() = %v", err) + } + if _, err = io.WriteString(cleartext, tc.body); err != nil { + t.Fatalf("io.WriteString() = %v", err) + } + if err = cleartext.Close(); err != nil { + if err.Error() == tc.expectedErr { + continue + } + t.Fatalf("ciphertext.Close() = %v", err) + } + if tc.expectedErr != "" { + t.Fatalf("Expected error %v, but got %v", tc.expectedErr, err) + } + switch tc.method { + case "encrypt": + validateEncrypt(t, buf) + case "sign": + validateSign(t, buf) + } + } +} + +func validateEncrypt(t *testing.T, buf bytes.Buffer) { + md, err := gpgbin.Decrypt(&buf) + if err != nil { + t.Errorf("Encrypt error: could not decrypt test encryption") + } + var body bytes.Buffer + io.Copy(&body, md.Body) + if s := body.String(); s != wantEncrypted { + t.Errorf("Encrypt() = \n%v\n but want \n%v", s, wantEncrypted) + } +} + +func validateSign(t *testing.T, buf bytes.Buffer) { + parts := strings.Split(buf.String(), "\r\n--foo\r\n") + msg := strings.NewReader(parts[1]) + sig := strings.NewReader(parts[2]) + md, err := gpgbin.Verify(msg, sig) + if err != nil { + t.Fatalf("gpg.Verify() = %v", err) + } + + deepEqual(t, "Sign", md, &wantSigned) +} + +var wantEncrypted = toCRLF(`Content-Type: text/plain + +This is an encrypted message! +`) + +var wantSignedBody = toCRLF(`Content-Type: text/plain + +This is a signed message! +`) + +var wantSigned = models.MessageDetails{ + IsEncrypted: false, + IsSigned: true, + SignedBy: "John Doe (This is a test key) <john.doe@example.org>", + SignedByKeyId: 3490876580878068068, + SignatureError: "", + DecryptedWith: "", + DecryptedWithKeyId: 0, + Body: strings.NewReader(wantSignedBody), + Micalg: "pgp-sha256", +} diff --git a/lib/crypto/pgp/pgp.go b/lib/crypto/pgp/pgp.go new file mode 100644 index 0000000..6195233 --- /dev/null +++ b/lib/crypto/pgp/pgp.go @@ -0,0 +1,328 @@ +package pgp + +import ( + "bytes" + "fmt" + "io" + "os" + "strings" + "time" + + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/xdg" + "git.sr.ht/~rjarry/aerc/models" + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/ProtonMail/go-crypto/openpgp/armor" + "github.com/ProtonMail/go-crypto/openpgp/packet" + "github.com/emersion/go-message/mail" + "github.com/emersion/go-pgpmail" + "github.com/pkg/errors" +) + +type Mail struct{} + +var ( + Keyring openpgp.EntityList + + locked bool +) + +func (m *Mail) KeyringExists() bool { + keypath := xdg.DataPath("aerc", "keyring.asc") + keyfile, err := os.Open(keypath) + if err != nil { + return false + } + defer keyfile.Close() + _, err = openpgp.ReadKeyRing(keyfile) + return err == nil +} + +func (m *Mail) Init() error { + log.Debugf("Initializing PGP keyring") + err := os.MkdirAll(xdg.DataPath("aerc"), 0o700) + if err != nil { + return fmt.Errorf("failed to create data directory: %w", err) + } + + lockpath := xdg.DataPath("aerc", "keyring.lock") + lockfile, err := os.OpenFile(lockpath, os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + // TODO: Consider connecting to main process over IPC socket + locked = false + } else { + locked = true + lockfile.Close() + } + + keypath := xdg.DataPath("aerc", "keyring.asc") + keyfile, err := os.Open(keypath) + if os.IsNotExist(err) { + return nil + } else if err != nil { + return err + } + defer keyfile.Close() + + Keyring, err = openpgp.ReadKeyRing(keyfile) + if err != nil { + return err + } + return nil +} + +func (m *Mail) Close() { + if !locked { + return + } + lockpath := xdg.DataPath("aerc", "keyring.lock") + os.Remove(lockpath) +} + +func (m *Mail) getEntityByEmail(email string) (e *openpgp.Entity, err error) { + for _, entity := range Keyring { + ident := entity.PrimaryIdentity() + if ident != nil && ident.UserId.Email == email { + return entity, nil + } + } + return nil, fmt.Errorf("entity not found in keyring") +} + +func (m *Mail) getSignerEntityByKeyId(id string) (*openpgp.Entity, error) { + id = strings.ToUpper(id) + for _, key := range Keyring.DecryptionKeys() { + if key.Entity == nil { + continue + } + kId := key.Entity.PrimaryKey.KeyIdString() + if strings.Contains(kId, id) { + return key.Entity, nil + } + } + return nil, fmt.Errorf("entity not found in keyring") +} + +func (m *Mail) getSignerEntityByEmail(email string) (e *openpgp.Entity, err error) { + for _, key := range Keyring.DecryptionKeys() { + if key.Entity == nil { + continue + } + ident := key.Entity.PrimaryIdentity() + if ident != nil && ident.UserId.Email == email { + return key.Entity, nil + } + } + return nil, fmt.Errorf("entity not found in keyring") +} + +func (m *Mail) Decrypt(r io.Reader, decryptKeys openpgp.PromptFunction) (*models.MessageDetails, error) { + md := new(models.MessageDetails) + + pgpReader, err := pgpmail.Read(r, Keyring, decryptKeys, nil) + if err != nil { + return nil, err + } + if pgpReader.MessageDetails.IsEncrypted { + md.IsEncrypted = true + md.DecryptedWith = pgpReader.MessageDetails.DecryptedWith.Entity.PrimaryIdentity().Name + md.DecryptedWithKeyId = pgpReader.MessageDetails.DecryptedWith.PublicKey.KeyId + } + if pgpReader.MessageDetails.IsSigned { + // we should consume the UnverifiedBody until EOF in order + // to get the correct signature data + data, err := io.ReadAll(pgpReader.MessageDetails.UnverifiedBody) + if err != nil { + return nil, err + } + pgpReader.MessageDetails.UnverifiedBody = bytes.NewReader(data) + + md.IsSigned = true + md.SignedBy = "" + md.SignedByKeyId = pgpReader.MessageDetails.SignedByKeyId + md.SignatureValidity = models.Valid + if pgpReader.MessageDetails.SignatureError != nil { + md.SignatureError = pgpReader.MessageDetails.SignatureError.Error() + md.SignatureValidity = handleSignatureError(md.SignatureError) + } + if pgpReader.MessageDetails.SignedBy != nil { + md.SignedBy = pgpReader.MessageDetails.SignedBy.Entity.PrimaryIdentity().Name + } + } + md.Body = pgpReader.MessageDetails.UnverifiedBody + return md, nil +} + +func (m *Mail) ImportKeys(r io.Reader) error { + keys, err := openpgp.ReadKeyRing(r) + if err != nil { + return err + } + Keyring = append(Keyring, keys...) + if locked { + keypath := xdg.DataPath("aerc", "keyring.asc") + keyfile, err := os.OpenFile(keypath, os.O_CREATE|os.O_APPEND, 0o600) + if err != nil { + return err + } + defer keyfile.Close() + + for _, key := range keys { + if key.PrivateKey != nil { + err = key.SerializePrivate(keyfile, &packet.Config{}) + } else { + err = key.Serialize(keyfile) + } + if err != nil { + return err + } + } + } + return nil +} + +func (m *Mail) Encrypt(buf *bytes.Buffer, rcpts []string, signer string, decryptKeys openpgp.PromptFunction, header *mail.Header) (io.WriteCloser, error) { + var err error + var to []*openpgp.Entity + var signerEntity *openpgp.Entity + if signer != "" { + signerEntity, err = m.getSigner(signer, decryptKeys) + if err != nil { + return nil, err + } + } + + for _, rcpt := range rcpts { + toEntity, err := m.getEntityByEmail(rcpt) + if err != nil { + return nil, errors.Wrap(err, "no key for "+rcpt) + } + to = append(to, toEntity) + } + + cleartext, err := pgpmail.Encrypt(buf, header.Header.Header, + to, signerEntity, nil) + if err != nil { + return nil, err + } + return cleartext, nil +} + +func (m *Mail) Sign(buf *bytes.Buffer, signer string, decryptKeys openpgp.PromptFunction, header *mail.Header) (io.WriteCloser, error) { + var err error + var signerEntity *openpgp.Entity + if signer != "" { + signerEntity, err = m.getSigner(signer, decryptKeys) + if err != nil { + return nil, err + } + } + cleartext, err := pgpmail.Sign(buf, header.Header.Header, signerEntity, nil) + if err != nil { + return nil, err + } + return cleartext, nil +} + +func (m *Mail) getSigner(signer string, decryptKeys openpgp.PromptFunction) (signerEntity *openpgp.Entity, err error) { + switch strings.Contains(signer, "@") { + case true: + signerEntity, err = m.getSignerEntityByEmail(signer) + if err != nil { + return nil, err + } + case false: + signerEntity, err = m.getSignerEntityByKeyId(signer) + if err != nil { + return nil, err + } + } + + key, ok := signerEntity.SigningKey(time.Now()) + if !ok { + return nil, fmt.Errorf("no signing key found for %s", signer) + } + + if !key.PrivateKey.Encrypted { + return signerEntity, nil + } + + _, err = decryptKeys([]openpgp.Key{key}, false) + if err != nil { + return nil, err + } + + return signerEntity, nil +} + +func (m *Mail) GetSignerKeyId(s string) (string, error) { + var err error + var signerEntity *openpgp.Entity + switch strings.Contains(s, "@") { + case true: + signerEntity, err = m.getSignerEntityByEmail(s) + if err != nil { + return "", err + } + case false: + signerEntity, err = m.getSignerEntityByKeyId(s) + if err != nil { + return "", err + } + } + return signerEntity.PrimaryKey.KeyIdString(), nil +} + +func (m *Mail) GetKeyId(s string) (string, error) { + entity, err := m.getEntityByEmail(s) + if err != nil { + return "", err + } + return entity.PrimaryKey.KeyIdString(), nil +} + +func (m *Mail) ExportKey(k string) (io.Reader, error) { + var err error + var entity *openpgp.Entity + switch strings.Contains(k, "@") { + case true: + entity, err = m.getSignerEntityByEmail(k) + if err != nil { + return nil, err + } + case false: + entity, err = m.getSignerEntityByKeyId(k) + if err != nil { + return nil, err + } + } + pks := bytes.NewBuffer(nil) + err = entity.Serialize(pks) + if err != nil { + return nil, fmt.Errorf("pgp: error exporting key: %w", err) + } + pka := bytes.NewBuffer(nil) + w, err := armor.Encode(pka, "PGP PUBLIC KEY BLOCK", map[string]string{}) + if err != nil { + return nil, fmt.Errorf("pgp: error exporting key: %w", err) + } + _, err = w.Write(pks.Bytes()) + if err != nil { + return nil, fmt.Errorf("pgp: error exporting key: %w", err) + } + w.Close() + return pka, nil +} + +func handleSignatureError(e string) models.SignatureValidity { + if e == "openpgp: signature made by unknown entity" { + return models.UnknownEntity + } + if strings.HasPrefix(e, "pgpmail: unsupported micalg") { + return models.UnsupportedMicalg + } + if strings.HasPrefix(e, "pgpmail") { + return models.InvalidSignature + } + return models.UnknownValidity +} diff --git a/lib/crypto/util/cleartext.go b/lib/crypto/util/cleartext.go new file mode 100644 index 0000000..fe6faa8 --- /dev/null +++ b/lib/crypto/util/cleartext.go @@ -0,0 +1,69 @@ +package cryptoutil + +import ( + "bytes" + "errors" + "io" + "strings" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/lib/rfc822" + "github.com/emersion/go-message/mail" +) + +func Cleartext(r io.Reader, header mail.Header) ([]byte, error) { + msg, err := app.CryptoProvider().Decrypt( + rfc822.NewCRLFReader(r), app.DecryptKeys) + if err != nil { + return nil, errors.New("decrypt error") + } + full, err := createMessage(header, msg.Body) + if err != nil { + return nil, errors.New("failed to create decrypted message") + } + return full, nil +} + +func createMessage(header mail.Header, body io.Reader) ([]byte, error) { + e, err := rfc822.ReadMessage(body) + if err != nil { + return nil, err + } + + // copy the header values from the "decrypted body". This should set + // the correct content type. + hf := e.Header.Fields() + for hf.Next() { + header.Set(hf.Key(), hf.Value()) + } + + ctype, params, err := header.ContentType() + if err != nil { + return nil, err + } + + // in case there remains a multipart/{encrypted,signed} content type, + // manually correct them to multipart/mixed as a fallback. + ct := strings.ToLower(ctype) + if strings.Contains(ct, "multipart/encrypted") || + strings.Contains(ct, "multipart/signed") { + delete(params, "protocol") + delete(params, "micalg") + header.SetContentType("multipart/mixed", params) + } + + // a SingleInlineWriter is sufficient since the "decrypted body" + // already contains the proper boundaries of the parts; we just want to + // combine it with the headers. + var message bytes.Buffer + w, err := mail.CreateSingleInlineWriter(&message, header) + if err != nil { + return nil, err + } + if _, err := io.Copy(w, e.Body); err != nil { + return nil, err + } + w.Close() + + return message.Bytes(), nil +} diff --git a/lib/dirstore.go b/lib/dirstore.go new file mode 100644 index 0000000..8749fb0 --- /dev/null +++ b/lib/dirstore.go @@ -0,0 +1,51 @@ +package lib + +import ( + "git.sr.ht/~rjarry/aerc/lib/sort" + "git.sr.ht/~rjarry/aerc/models" +) + +type DirStore struct { + dirs map[string]*models.Directory + msgStores map[string]*MessageStore + order []string +} + +func NewDirStore() *DirStore { + return &DirStore{ + dirs: make(map[string]*models.Directory), + msgStores: make(map[string]*MessageStore), + } +} + +func (store *DirStore) List() []string { + dirs := []string{} + for dir := range store.msgStores { + dirs = append(dirs, dir) + } + sort.SortStringBy(dirs, store.order) + return dirs +} + +func (store *DirStore) MessageStore(dirname string) (*MessageStore, bool) { + msgStore, ok := store.msgStores[dirname] + return msgStore, ok +} + +func (store *DirStore) SetMessageStore(dir *models.Directory, msgStore *MessageStore) { + s := dir.Name + if _, ok := store.dirs[s]; !ok { + store.order = append(store.order, s) + } + store.dirs[dir.Name] = dir + store.msgStores[dir.Name] = msgStore +} + +func (store *DirStore) Remove(name string) { + delete(store.dirs, name) + delete(store.msgStores, name) +} + +func (store *DirStore) Directory(name string) *models.Directory { + return store.dirs[name] +} diff --git a/lib/dirstore_test.go b/lib/dirstore_test.go new file mode 100644 index 0000000..b1ba4eb --- /dev/null +++ b/lib/dirstore_test.go @@ -0,0 +1,23 @@ +package lib_test + +import ( + "reflect" + "testing" + + "git.sr.ht/~rjarry/aerc/lib" + "git.sr.ht/~rjarry/aerc/models" +) + +func TestDirStore_List(t *testing.T) { + dirs := []string{"a/c", "x", "a/b", "d"} + dirstore := lib.NewDirStore() + for _, d := range dirs { + dirstore.SetMessageStore(&models.Directory{Name: d}, nil) + } + for i := 0; i < 10; i++ { + if !reflect.DeepEqual(dirstore.List(), dirs) { + t.Errorf("order does not match") + return + } + } +} diff --git a/lib/emlview.go b/lib/emlview.go new file mode 100644 index 0000000..a5740a1 --- /dev/null +++ b/lib/emlview.go @@ -0,0 +1,84 @@ +package lib + +import ( + "bytes" + "io" + + "github.com/ProtonMail/go-crypto/openpgp" + _ "github.com/emersion/go-message/charset" + + "git.sr.ht/~rjarry/aerc/lib/crypto" + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/rfc822" + "git.sr.ht/~rjarry/aerc/models" +) + +// EmlMessage implements the RawMessage interface +type EmlMessage []byte + +func (fm *EmlMessage) NewReader() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(*fm)), nil +} + +func (fm *EmlMessage) UID() models.UID { + return "" +} + +func (fm *EmlMessage) Labels() ([]string, error) { + return nil, nil +} + +func (fm *EmlMessage) ModelFlags() (models.Flags, error) { + return models.SeenFlag, nil +} + +// NewEmlMessageView provides a MessageView for a full message that is not +// stored in a message store +func NewEmlMessageView(full []byte, pgp crypto.Provider, + decryptKeys openpgp.PromptFunction, cb func(MessageView, error), +) { + eml := EmlMessage(full) + messageInfo, err := rfc822.MessageInfo(&eml) + if err != nil { + cb(nil, err) + return + } + msv := &MessageStoreView{ + messageInfo: messageInfo, + messageStore: nil, + message: full, + details: nil, + bodyStructure: nil, + setSeen: false, + } + + if usePGP(messageInfo.BodyStructure) { + reader := rfc822.NewCRLFReader(bytes.NewReader(full)) + md, err := pgp.Decrypt(reader, decryptKeys) + if err != nil { + cb(nil, err) + return + } + msv.details = md + msv.message, err = io.ReadAll(md.Body) + if err != nil { + cb(nil, err) + return + } + } + entity, err := rfc822.ReadMessage(bytes.NewBuffer(msv.message)) + if err != nil { + cb(nil, err) + return + } + bs, err := rfc822.ParseEntityStructure(entity) + if rfc822.IsMultipartError(err) { + log.Warnf("EmlView: %v", err) + bs = rfc822.CreateTextPlainBody() + } else if err != nil { + cb(nil, err) + return + } + msv.bodyStructure = bs + cb(msv, nil) +} diff --git a/lib/format/format.go b/lib/format/format.go new file mode 100644 index 0000000..430abf7 --- /dev/null +++ b/lib/format/format.go @@ -0,0 +1,88 @@ +package format + +import ( + "fmt" + "strings" + "time" + "unicode" + + "github.com/emersion/go-message/mail" +) + +const rfc5322specials string = `()<>[]:;@\,."` + +// AddressForHumans formats the address. +// Meant for display purposes to the humans, not for sending over the wire. +func AddressForHumans(a *mail.Address) string { + if a.Name != "" { + if strings.ContainsAny(a.Name, rfc5322specials) { + return fmt.Sprintf("\"%s\" <%s>", + strings.ReplaceAll(a.Name, "\"", "'"), a.Address) + } else { + return fmt.Sprintf("%s <%s>", a.Name, a.Address) + } + } else { + return fmt.Sprintf("<%s>", a.Address) + } +} + +// FormatAddresses formats a list of addresses into a human readable string +func FormatAddresses(l []*mail.Address) string { + formatted := make([]string, len(l)) + for i, a := range l { + formatted[i] = AddressForHumans(a) + } + return strings.Join(formatted, ", ") +} + +// CompactPath reduces a directory path into a compact form. The directory +// name will be split with the provided separator and each part will be reduced +// to the first letter in its name: INBOX/01_WORK/PROJECT will become +// I/W/PROJECT. +func CompactPath(name string, sep rune) (compact string) { + parts := strings.Split(name, string(sep)) + for i, part := range parts { + if i == len(parts)-1 { + compact += part + } else { + if len(part) != 0 { + r := part[0] + for i := 0; i < len(part)-1; i++ { + if unicode.IsLetter(rune(part[i])) { + r = part[i] + break + } + } + compact += fmt.Sprintf("%c%c", r, sep) + } else { + compact += fmt.Sprintf("%c", sep) + } + } + } + return +} + +func DummyIfZeroDate(date time.Time, format string, todayFormat string, + thisWeekFormat string, thisYearFormat string, +) string { + if date.IsZero() { + return strings.Repeat("?", len(format)) + } + year := date.Year() + day := date.YearDay() + now := time.Now() + thisYear := now.Year() + thisDay := now.YearDay() + if year == thisYear { + if day == thisDay && todayFormat != "" { + return date.Format(todayFormat) + } + if day > thisDay-7 && thisWeekFormat != "" { + return date.Format(thisWeekFormat) + } + if thisYearFormat != "" { + return date.Format(thisYearFormat) + } + } + return date.Format(format) +} diff --git a/lib/history.go b/lib/history.go new file mode 100644 index 0000000..abc081f --- /dev/null +++ b/lib/history.go @@ -0,0 +1,13 @@ +package lib + +// History represents a list of elements ordered by time. +type History interface { + // Add a new element to the history + Add(string) + // Get the next element in history + Next() string + // Get the previous element in history + Prev() string + // Reset the current location in history + Reset() +} diff --git a/lib/hooks/aerc-shutdown.go b/lib/hooks/aerc-shutdown.go new file mode 100644 index 0000000..a3f55f7 --- /dev/null +++ b/lib/hooks/aerc-shutdown.go @@ -0,0 +1,22 @@ +package hooks + +import ( + "fmt" + "time" + + "git.sr.ht/~rjarry/aerc/config" +) + +type AercShutdown struct { + Lifetime time.Duration +} + +func (a *AercShutdown) Cmd() string { + return config.Hooks.AercShutdown +} + +func (a *AercShutdown) Env() []string { + return []string{ + fmt.Sprintf("AERC_LIFETIME=%s", a.Lifetime.String()), + } +} diff --git a/lib/hooks/aerc-startup.go b/lib/hooks/aerc-startup.go new file mode 100644 index 0000000..a53d070 --- /dev/null +++ b/lib/hooks/aerc-startup.go @@ -0,0 +1,23 @@ +package hooks + +import ( + "fmt" + "os" + + "git.sr.ht/~rjarry/aerc/config" +) + +type AercStartup struct { + Version string +} + +func (m *AercStartup) Cmd() string { + return config.Hooks.AercStartup +} + +func (m *AercStartup) Env() []string { + return []string{ + fmt.Sprintf("AERC_VERSION=%s", m.Version), + fmt.Sprintf("AERC_BINARY=%s", os.Args[0]), + } +} diff --git a/lib/hooks/exec.go b/lib/hooks/exec.go new file mode 100644 index 0000000..71542c9 --- /dev/null +++ b/lib/hooks/exec.go @@ -0,0 +1,31 @@ +package hooks + +import ( + "bytes" + "os" + "os/exec" + + "git.sr.ht/~rjarry/aerc/lib/log" +) + +func RunHook(h HookType) error { + cmd := h.Cmd() + if cmd == "" { + return nil + } + env := h.Env() + log.Debugf("hooks: running command %q (env %v)", cmd, env) + + proc := exec.Command("sh", "-c", cmd) + var outb, errb bytes.Buffer + proc.Stdout = &outb + proc.Stderr = &errb + proc.Env = os.Environ() + proc.Env = append(proc.Env, env...) + err := proc.Run() + log.Tracef("hooks: %q stdout: %s", cmd, outb.String()) + if err != nil { + log.Errorf("hooks:%q stderr: %s", cmd, errb.String()) + } + return err +} diff --git a/lib/hooks/flag-changed.go b/lib/hooks/flag-changed.go new file mode 100644 index 0000000..2e4574c --- /dev/null +++ b/lib/hooks/flag-changed.go @@ -0,0 +1,31 @@ +package hooks + +import ( + "fmt" + + "git.sr.ht/~rjarry/aerc/config" +) + +type FlagChanged struct { + Account string + Backend string + Folder string + Role string + FlagName string +} + +func (m *FlagChanged) Cmd() string { + return config.Hooks.FlagChanged +} + +func (m *FlagChanged) Env() []string { + env := []string{ + fmt.Sprintf("AERC_ACCOUNT=%s", m.Account), + fmt.Sprintf("AERC_ACCOUNT_BACKEND=%s", m.Backend), + fmt.Sprintf("AERC_FOLDER=%s", m.Folder), + fmt.Sprintf("AERC_FOLDER_ROLE=%s", m.Role), + fmt.Sprintf("AERC_FLAG=%s", m.FlagName), + } + + return env +} diff --git a/lib/hooks/interface.go b/lib/hooks/interface.go new file mode 100644 index 0000000..ed38c3a --- /dev/null +++ b/lib/hooks/interface.go @@ -0,0 +1,6 @@ +package hooks + +type HookType interface { + Cmd() string + Env() []string +} diff --git a/lib/hooks/mail-added.go b/lib/hooks/mail-added.go new file mode 100644 index 0000000..efb3a7d --- /dev/null +++ b/lib/hooks/mail-added.go @@ -0,0 +1,27 @@ +package hooks + +import ( + "fmt" + + "git.sr.ht/~rjarry/aerc/config" +) + +type MailAdded struct { + Account string + Backend string + Folder string + Role string +} + +func (m *MailAdded) Cmd() string { + return config.Hooks.MailAdded +} + +func (m *MailAdded) Env() []string { + return []string{ + fmt.Sprintf("AERC_ACCOUNT=%s", m.Account), + fmt.Sprintf("AERC_ACCOUNT_BACKEND=%s", m.Backend), + fmt.Sprintf("AERC_FOLDER=%s", m.Folder), + fmt.Sprintf("AERC_FOLDER_ROLE=%s", m.Role), + } +} diff --git a/lib/hooks/mail-deleted.go b/lib/hooks/mail-deleted.go new file mode 100644 index 0000000..ce36e2f --- /dev/null +++ b/lib/hooks/mail-deleted.go @@ -0,0 +1,27 @@ +package hooks + +import ( + "fmt" + + "git.sr.ht/~rjarry/aerc/config" +) + +type MailDeleted struct { + Account string + Backend string + Folder string + Role string +} + +func (m *MailDeleted) Cmd() string { + return config.Hooks.MailDeleted +} + +func (m *MailDeleted) Env() []string { + return []string{ + fmt.Sprintf("AERC_ACCOUNT=%s", m.Account), + fmt.Sprintf("AERC_ACCOUNT_BACKEND=%s", m.Backend), + fmt.Sprintf("AERC_FOLDER=%s", m.Folder), + fmt.Sprintf("AERC_FOLDER_ROLE=%s", m.Role), + } +} diff --git a/lib/hooks/mail-received.go b/lib/hooks/mail-received.go new file mode 100644 index 0000000..fa8247d --- /dev/null +++ b/lib/hooks/mail-received.go @@ -0,0 +1,34 @@ +package hooks + +import ( + "fmt" + + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/models" +) + +type MailReceived struct { + Account string + Backend string + Folder string + Role string + MsgInfo *models.MessageInfo +} + +func (m *MailReceived) Cmd() string { + return config.Hooks.MailReceived +} + +func (m *MailReceived) Env() []string { + from := m.MsgInfo.Envelope.From[0] + return []string{ + fmt.Sprintf("AERC_ACCOUNT=%s", m.Account), + fmt.Sprintf("AERC_ACCOUNT_BACKEND=%s", m.Backend), + fmt.Sprintf("AERC_FOLDER=%s", m.Folder), + fmt.Sprintf("AERC_FROM_NAME=%s", from.Name), + fmt.Sprintf("AERC_FROM_ADDRESS=%s", from.Address), + fmt.Sprintf("AERC_SUBJECT=%s", m.MsgInfo.Envelope.Subject), + fmt.Sprintf("AERC_MESSAGE_ID=%s", m.MsgInfo.Envelope.MessageId), + fmt.Sprintf("AERC_FOLDER_ROLE=%s", m.Role), + } +} diff --git a/lib/hooks/mail-sent.go b/lib/hooks/mail-sent.go new file mode 100644 index 0000000..393670a --- /dev/null +++ b/lib/hooks/mail-sent.go @@ -0,0 +1,33 @@ +package hooks + +import ( + "fmt" + + "git.sr.ht/~rjarry/aerc/config" + "github.com/emersion/go-message/mail" +) + +type MailSent struct { + Account string + Backend string + Header *mail.Header +} + +func (m *MailSent) Cmd() string { + return config.Hooks.MailSent +} + +func (m *MailSent) Env() []string { + from, _ := mail.ParseAddress(m.Header.Get("From")) + env := []string{ + fmt.Sprintf("AERC_ACCOUNT=%s", m.Account), + fmt.Sprintf("AERC_ACCOUNT_BACKEND=%s", m.Backend), + fmt.Sprintf("AERC_FROM_NAME=%s", from.Name), + fmt.Sprintf("AERC_FROM_ADDRESS=%s", from.Address), + fmt.Sprintf("AERC_SUBJECT=%s", m.Header.Get("Subject")), + fmt.Sprintf("AERC_TO=%s", m.Header.Get("To")), + fmt.Sprintf("AERC_CC=%s", m.Header.Get("Cc")), + } + + return env +} diff --git a/lib/hooks/tag-modified.go b/lib/hooks/tag-modified.go new file mode 100644 index 0000000..2185280 --- /dev/null +++ b/lib/hooks/tag-modified.go @@ -0,0 +1,28 @@ +package hooks + +import ( + "fmt" + + "git.sr.ht/~rjarry/aerc/config" +) + +type TagModified struct { + Account string + Backend string + Add []string + Remove []string +} + +func (m *TagModified) Cmd() string { + return config.Hooks.TagModified +} + +func (m *TagModified) Env() []string { + env := []string{ + fmt.Sprintf("AERC_ACCOUNT=%s", m.Account), + fmt.Sprintf("AERC_TAG_ADDED=%v", m.Add), + fmt.Sprintf("AERC_TAG_REMOVED=%v", m.Remove), + } + + return env +} diff --git a/lib/ipc/handler.go b/lib/ipc/handler.go new file mode 100644 index 0000000..9ed81be --- /dev/null +++ b/lib/ipc/handler.go @@ -0,0 +1,5 @@ +package ipc + +type Handler interface { + Command(args []string) error +} diff --git a/lib/ipc/message.go b/lib/ipc/message.go new file mode 100644 index 0000000..443eaf7 --- /dev/null +++ b/lib/ipc/message.go @@ -0,0 +1,52 @@ +package ipc + +import "encoding/json" + +// Request contains all parameters needed for the main instance to respond to +// a request. +type Request struct { + // Arguments contains the commandline arguments. The detection of what + // action to take is left to the receiver. + Arguments []string `json:"arguments"` +} + +// Response is used to report the results of a command. +type Response struct { + // Error contains the success-state of the command. Error is an empty + // string if everything ran successfully. + Error string `json:"error"` +} + +// Encode transforms the message in an easier to transfer format +func (msg *Request) Encode() ([]byte, error) { + return json.Marshal(msg) +} + +// DecodeMessage consumes a raw message and returns the message contained +// within. +func DecodeMessage(data []byte) (*Request, error) { + msg := new(Request) + err := json.Unmarshal(data, msg) + return msg, err +} + +// Encode transforms the message in an easier to transfer format +func (msg *Response) Encode() ([]byte, error) { + return json.Marshal(msg) +} + +// DecodeRequest consumes a raw message and returns the message contained +// within. +func DecodeRequest(data []byte) (*Request, error) { + msg := new(Request) + err := json.Unmarshal(data, msg) + return msg, err +} + +// DecodeResponse consumes a raw message and returns the message contained +// within. +func DecodeResponse(data []byte) (*Response, error) { + msg := new(Response) + err := json.Unmarshal(data, msg) + return msg, err +} diff --git a/lib/ipc/receive.go b/lib/ipc/receive.go new file mode 100644 index 0000000..bba365b --- /dev/null +++ b/lib/ipc/receive.go @@ -0,0 +1,104 @@ +package ipc + +import ( + "bufio" + "context" + "errors" + "net" + "os" + "sync/atomic" + "time" + + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/xdg" +) + +type AercServer struct { + listener net.Listener + handler Handler + startup context.Context +} + +func StartServer(handler Handler, startup context.Context) (*AercServer, error) { + sockpath := xdg.RuntimePath("aerc.sock") + // remove the socket if it is not connected to a session + if _, err := ConnectAndExec(nil); err != nil { + os.Remove(sockpath) + } + log.Debugf("Starting Unix server: %s", sockpath) + l, err := net.Listen("unix", sockpath) + if err != nil { + return nil, err + } + as := &AercServer{listener: l, handler: handler, startup: startup} + go as.Serve() + + return as, nil +} + +func (as *AercServer) Close() { + as.listener.Close() +} + +var lastId int64 = 0 // access via atomic + +func (as *AercServer) Serve() { + defer log.PanicHandler() + + <-as.startup.Done() + + for { + conn, err := as.listener.Accept() + switch { + case errors.Is(err, net.ErrClosed): + log.Infof("shutting down UNIX listener") + return + case err != nil: + log.Errorf("ipc: accepting connection failed: %v", err) + continue + } + + defer conn.Close() + clientId := atomic.AddInt64(&lastId, 1) + log.Debugf("unix:%d accepted connection", clientId) + scanner := bufio.NewScanner(conn) + err = conn.SetDeadline(time.Now().Add(1 * time.Minute)) + if err != nil { + log.Errorf("unix:%d failed to set deadline: %v", clientId, err) + } + for scanner.Scan() { + // allow up to 1 minute between commands + err = conn.SetDeadline(time.Now().Add(1 * time.Minute)) + if err != nil { + log.Errorf("unix:%d failed to update deadline: %v", clientId, err) + } + msg, err := DecodeRequest(scanner.Bytes()) + log.Tracef("unix:%d got message %s", clientId, scanner.Text()) + if err != nil { + log.Errorf("unix:%d failed to parse request: %v", clientId, err) + continue + } + + response := as.handleMessage(msg) + result, err := response.Encode() + if err != nil { + log.Errorf("unix:%d failed to encode result: %v", clientId, err) + continue + } + _, err = conn.Write(append(result, '\n')) + if err != nil { + log.Errorf("unix:%d failed to send response: %v", clientId, err) + break + } + } + log.Tracef("unix:%d closed connection", clientId) + } +} + +func (as *AercServer) handleMessage(req *Request) *Response { + err := as.handler.Command(req.Arguments) + if err != nil { + return &Response{Error: err.Error()} + } + return &Response{} +} diff --git a/lib/ipc/send.go b/lib/ipc/send.go new file mode 100644 index 0000000..d5138bf --- /dev/null +++ b/lib/ipc/send.go @@ -0,0 +1,39 @@ +package ipc + +import ( + "bufio" + "errors" + "fmt" + "net" + + "git.sr.ht/~rjarry/aerc/lib/xdg" +) + +func ConnectAndExec(args []string) (*Response, error) { + sockpath := xdg.RuntimePath("aerc.sock") + conn, err := net.Dial("unix", sockpath) + if err != nil { + return nil, err + } + defer conn.Close() + + req, err := (&Request{Arguments: args}).Encode() + if err != nil { + return nil, fmt.Errorf("failed to encode request: %w", err) + } + + _, err = conn.Write(append(req, '\n')) + if err != nil { + return nil, fmt.Errorf("failed to send message: %w", err) + } + scanner := bufio.NewScanner(conn) + if !scanner.Scan() { + return nil, errors.New("No response from server") + } + resp, err := DecodeResponse(scanner.Bytes()) + if err != nil { + return nil, err + } + + return resp, nil +} diff --git a/lib/iterator/impl.go b/lib/iterator/impl.go new file mode 100644 index 0000000..be8c382 --- /dev/null +++ b/lib/iterator/impl.go @@ -0,0 +1,126 @@ +package iterator + +import ( + "errors" + + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/types" +) + +// defaultFactory +type defaultFactory struct{} + +func (df *defaultFactory) NewIterator(a interface{}) Iterator { + switch data := a.(type) { + case []models.UID: + return &defaultUid{data: data, index: len(data)} + case []*types.Thread: + return &defaultThread{data: data, index: len(data)} + } + panic(errors.New("a iterator for this type is not implemented yet")) +} + +// defaultUid +type defaultUid struct { + data []models.UID + index int +} + +func (du *defaultUid) Next() bool { + du.index-- + return du.index >= 0 +} + +func (du *defaultUid) Value() interface{} { + return du.data[du.index] +} + +func (du *defaultUid) StartIndex() int { + return len(du.data) - 1 +} + +func (du *defaultUid) EndIndex() int { + return 0 +} + +// defaultThread +type defaultThread struct { + data []*types.Thread + index int +} + +func (dt *defaultThread) Next() bool { + dt.index-- + return dt.index >= 0 +} + +func (dt *defaultThread) Value() interface{} { + return dt.data[dt.index] +} + +func (dt *defaultThread) StartIndex() int { + return len(dt.data) - 1 +} + +func (dt *defaultThread) EndIndex() int { + return 0 +} + +// reverseFactory +type reverseFactory struct{} + +func (rf *reverseFactory) NewIterator(a interface{}) Iterator { + switch data := a.(type) { + case []models.UID: + return &reverseUid{data: data, index: -1} + case []*types.Thread: + return &reverseThread{data: data, index: -1} + } + panic(errors.New("an iterator for this type is not implemented yet")) +} + +// reverseUid +type reverseUid struct { + data []models.UID + index int +} + +func (ru *reverseUid) Next() bool { + ru.index++ + return ru.index < len(ru.data) +} + +func (ru *reverseUid) Value() interface{} { + return ru.data[ru.index] +} + +func (ru *reverseUid) StartIndex() int { + return 0 +} + +func (ru *reverseUid) EndIndex() int { + return len(ru.data) - 1 +} + +// reverseThread +type reverseThread struct { + data []*types.Thread + index int +} + +func (rt *reverseThread) Next() bool { + rt.index++ + return rt.index < len(rt.data) +} + +func (rt *reverseThread) Value() interface{} { + return rt.data[rt.index] +} + +func (rt *reverseThread) StartIndex() int { + return 0 +} + +func (rt *reverseThread) EndIndex() int { + return len(rt.data) - 1 +} diff --git a/lib/iterator/index.go b/lib/iterator/index.go new file mode 100644 index 0000000..e3d576d --- /dev/null +++ b/lib/iterator/index.go @@ -0,0 +1,54 @@ +package iterator + +// IndexProvider implements a subset of the Iterator interface +type IndexProvider interface { + StartIndex() int + EndIndex() int +} + +// FixBounds will force the index i to either its lower- or upper-bound value +// if out-of-bound +func FixBounds(i, lower, upper int) int { + switch { + case i > upper: + i = upper + case i < lower: + i = lower + } + return i +} + +// WrapBounds will wrap the index i around its upper- or lower-bound if +// out-of-bound +func WrapBounds(i, lower, upper int) int { + if upper <= 0 { + return lower + } + switch { + case i > upper: + i = lower + (i-upper-1)%upper + case i < lower: + i = upper - (lower-i-1)%upper + } + return i +} + +type BoundsCheckFunc func(int, int, int) int + +// MoveIndex moves the index variable idx forward by delta steps and ensures +// that the boundary policy as defined by the CheckBoundsFunc is enforced. +// +// If CheckBoundsFunc is nil, fix boundary checks are performed. +func MoveIndex(idx, delta int, indexer IndexProvider, cb BoundsCheckFunc) int { + lower, upper := indexer.StartIndex(), indexer.EndIndex() + sign := 1 + if upper < lower { + lower, upper = upper, lower + sign = -1 + } + result := idx + sign*delta + if cb == nil { + return FixBounds(result, lower, upper) + } + return cb(result, lower, upper) +} diff --git a/lib/iterator/index_test.go b/lib/iterator/index_test.go new file mode 100644 index 0000000..c9b7930 --- /dev/null +++ b/lib/iterator/index_test.go @@ -0,0 +1,133 @@ +package iterator_test + +import ( + "testing" + + "git.sr.ht/~rjarry/aerc/lib/iterator" +) + +type indexer struct { + start int + end int +} + +func (ip *indexer) StartIndex() int { + return ip.start +} + +func (ip *indexer) EndIndex() int { + return ip.end +} + +func TestMoveIndex(t *testing.T) { + tests := []struct { + idx int + delta int + start int + end int + cb iterator.BoundsCheckFunc + expected int + }{ + { + idx: 0, + delta: 1, + start: 0, + end: 2, + cb: iterator.FixBounds, + expected: 1, + }, + { + idx: 0, + delta: 5, + start: 0, + end: 2, + cb: iterator.FixBounds, + expected: 2, + }, + { + idx: 0, + delta: -1, + start: 0, + end: 2, + cb: iterator.FixBounds, + expected: 0, + }, + { + idx: 0, + delta: 2, + start: 0, + end: 2, + cb: iterator.WrapBounds, + expected: 2, + }, + { + idx: 0, + delta: 3, + start: 0, + end: 2, + cb: iterator.WrapBounds, + expected: 0, + }, + { + idx: 0, + delta: -1, + start: 0, + end: 2, + cb: iterator.WrapBounds, + expected: 2, + }, + { + idx: 2, + delta: 2, + start: 0, + end: 2, + cb: iterator.WrapBounds, + expected: 1, + }, + { + idx: 0, + delta: -2, + start: 0, + end: 2, + cb: iterator.WrapBounds, + expected: 1, + }, + { + idx: 1, + delta: 1, + start: 2, + end: 0, + cb: iterator.FixBounds, + expected: 0, + }, + { + idx: 0, + delta: 1, + start: 2, + end: 0, + cb: iterator.FixBounds, + expected: 0, + }, + { + idx: 0, + delta: 1, + start: 2, + end: 0, + cb: iterator.WrapBounds, + expected: 2, + }, + } + + for i, test := range tests { + idx := iterator.MoveIndex( + test.idx, + test.delta, + &indexer{test.start, test.end}, + test.cb, + ) + if idx != test.expected { + t.Errorf("test %d [%#v] failed: got %d but expected %d", + i, test, idx, test.expected) + } + } +} diff --git a/lib/iterator/iterator.go b/lib/iterator/iterator.go new file mode 100644 index 0000000..28a9b8b --- /dev/null +++ b/lib/iterator/iterator.go @@ -0,0 +1,35 @@ +package iterator + +// Factory is the interface that wraps the NewIterator method. The +// NewIterator() creates either UID or thread iterators and ensures that both +// types of iterators implement the same iteration direction. +type Factory interface { + NewIterator(a interface{}) Iterator +} + +// Iterator implements an interface for iterating over UID or thread data. If +// Next() returns true, the current value of the iterator can be read with +// Value(). The return value of Value() is an interface{} type which needs to +// be cast to the correct type. +// +// The iterators are implemented such that the first returned value always +// represents the top message in the message list. Hence, StartIndex() would +// return the index of the top message whereas EndIndex() returns the index of +// message at the bottom of the list. +type Iterator interface { + Next() bool + Value() interface{} + StartIndex() int + EndIndex() int +} + +// NewFactory creates an iterator factory. When reverse is true, the iterators +// are reversed in the sense that the lowest UID messages are displayed at the +// top of the message list. Otherwise, the default order is with the highest +// UID message on top. +func NewFactory(reverse bool) Factory { + if reverse { + return &reverseFactory{} + } + return &defaultFactory{} +} diff --git a/lib/iterator/iterator_test.go b/lib/iterator/iterator_test.go new file mode 100644 index 0000000..9e6da43 --- /dev/null +++ b/lib/iterator/iterator_test.go @@ -0,0 +1,96 @@ +package iterator_test + +import ( + "testing" + + "git.sr.ht/~rjarry/aerc/lib/iterator" + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/types" +) + +func toThreads(uids []models.UID) []*types.Thread { + threads := make([]*types.Thread, len(uids)) + for i, u := range uids { + threads[i] = &types.Thread{Uid: u} + } + return threads +} + +func TestIterator_DefaultFactory(t *testing.T) { + input := []models.UID{"1", "2", "3", "4", "5", "6", "7", "8", "9"} + want := []models.UID{"9", "8", "7", "6", "5", "4", "3", "2", "1"} + + factory := iterator.NewFactory(false) + if factory == nil { + t.Errorf("could not create factory") + } + start, end := len(input)-1, 0 + checkUids(t, factory, input, want, start, end) + checkThreads(t, factory, toThreads(input), + toThreads(want), start, end) +} + +func TestIterator_ReverseFactory(t *testing.T) { + input := []models.UID{"1", "2", "3", "4", "5", "6", "7", "8", "9"} + want := []models.UID{"1", "2", "3", "4", "5", "6", "7", "8", "9"} + + factory := iterator.NewFactory(true) + if factory == nil { + t.Errorf("could not create factory") + } + + start, end := 0, len(input)-1 + checkUids(t, factory, input, want, start, end) + checkThreads(t, factory, toThreads(input), + toThreads(want), start, end) +} + +func checkUids(t *testing.T, factory iterator.Factory, + input []models.UID, want []models.UID, start, end int, +) { + label := "uids" + got := make([]models.UID, 0) + iter := factory.NewIterator(input) + for iter.Next() { + got = append(got, iter.Value().(models.UID)) + } + if len(got) != len(want) { + t.Errorf("%s: number of elements not correct", label) + } + for i, u := range want { + if got[i] != u { + t.Errorf("%s: order not correct", label) + } + } + if iter.StartIndex() != start { + t.Errorf("%s: start index not correct", label) + } + if iter.EndIndex() != end { + t.Errorf("%s: end index not correct", label) + } +} + +func checkThreads(t *testing.T, factory iterator.Factory, + input []*types.Thread, want []*types.Thread, start, end int, +) { + label := "threads" + got := make([]*types.Thread, 0) + iter := factory.NewIterator(input) + for iter.Next() { + got = append(got, iter.Value().(*types.Thread)) + } + if len(got) != len(want) { + t.Errorf("%s: number of elements not correct", label) + } + for i, th := range want { + if got[i].Uid != th.Uid { + t.Errorf("%s: order not correct", label) + } + } + if iter.StartIndex() != start { + t.Errorf("%s: start index not correct", label) + } + if iter.EndIndex() != end { + t.Errorf("%s: end index not correct", label) + } +} diff --git a/lib/keepalive_dummy.go b/lib/keepalive_dummy.go new file mode 100644 index 0000000..d455a42 --- /dev/null +++ b/lib/keepalive_dummy.go @@ -0,0 +1,12 @@ +//go:build !linux +// +build !linux + +package lib + +func SetTcpKeepaliveProbes(fd, count int) error { + return nil +} + +func SetTcpKeepaliveInterval(fd, interval int) error { + return nil +} diff --git a/lib/keepalive_linux.go b/lib/keepalive_linux.go new file mode 100644 index 0000000..4811338 --- /dev/null +++ b/lib/keepalive_linux.go @@ -0,0 +1,18 @@ +//go:build linux +// +build linux + +package lib + +import ( + "syscall" +) + +func SetTcpKeepaliveProbes(fd, count int) error { + return syscall.SetsockoptInt( + fd, syscall.IPPROTO_TCP, syscall.TCP_KEEPCNT, count) +} + +func SetTcpKeepaliveInterval(fd, interval int) error { + return syscall.SetsockoptInt( + fd, syscall.IPPROTO_TCP, syscall.TCP_KEEPINTVL, interval) +} diff --git a/lib/log/logger.go b/lib/log/logger.go new file mode 100644 index 0000000..80c78d1 --- /dev/null +++ b/lib/log/logger.go @@ -0,0 +1,188 @@ +package log + +import ( + "fmt" + "io" + "log" + "os" + "strings" +) + +type LogLevel int + +const ( + TRACE LogLevel = 5 + DEBUG LogLevel = 10 + INFO LogLevel = 20 + WARN LogLevel = 30 + ERROR LogLevel = 40 +) + +type logfilePtr struct { + f *os.File + useStdout bool +} + +func newLogfilePtr(f *os.File, isStdout bool) *logfilePtr { + return &logfilePtr{f: f, useStdout: isStdout} +} + +func (l *logfilePtr) Close() error { + if l.useStdout || l.f == nil { + return nil + } + return l.f.Close() +} + +var ( + trace *log.Logger + dbg *log.Logger + info *log.Logger + warn *log.Logger + err *log.Logger + minLevel LogLevel = TRACE + + // logfile stores a pointer to the log file descriptor + logfile *logfilePtr +) + +func Init(file *os.File, useStdout bool, level LogLevel) error { + trace = nil + dbg = nil + info = nil + warn = nil + err = nil + + if logfile != nil { + e := logfile.Close() + if e != nil { + return e + } + logfile = nil + } + + minLevel = level + flags := log.Ldate | log.Ltime | log.Lmicroseconds | log.Lshortfile + if file != nil { + logfile = newLogfilePtr(file, useStdout) + trace = log.New(file, "TRACE ", flags) + dbg = log.New(file, "DEBUG ", flags) + info = log.New(file, "INFO ", flags) + warn = log.New(file, "WARN ", flags) + err = log.New(file, "ERROR ", flags) + } + + return nil +} + +func ParseLevel(value string) (LogLevel, error) { + switch strings.ToLower(value) { + case "trace": + return TRACE, nil + case "debug": + return DEBUG, nil + case "info": + return INFO, nil + case "warn", "warning": + return WARN, nil + case "err", "error": + return ERROR, nil + } + return 0, fmt.Errorf("%s: invalid log level", value) +} + +func ErrorLogger() *log.Logger { + if err == nil { + return log.New(io.Discard, "", log.LstdFlags) + } + return err +} + +type Logger interface { + Tracef(string, ...interface{}) + Debugf(string, ...interface{}) + Infof(string, ...interface{}) + Warnf(string, ...interface{}) + Errorf(string, ...interface{}) +} + +type logger struct { + name string + calldepth int +} + +func NewLogger(name string, calldepth int) Logger { + return &logger{name: name, calldepth: calldepth} +} + +func (l *logger) format(message string, args ...interface{}) string { + if len(args) > 0 { + message = fmt.Sprintf(message, args...) + } + if l.name != "" { + message = fmt.Sprintf("[%s] %s", l.name, message) + } + return message +} + +func (l *logger) Tracef(message string, args ...interface{}) { + if trace == nil || minLevel > TRACE { + return + } + message = l.format(message, args...) + trace.Output(l.calldepth, message) //nolint:errcheck // we can't do anything with what we log +} + +func (l *logger) Debugf(message string, args ...interface{}) { + if dbg == nil || minLevel > DEBUG { + return + } + message = l.format(message, args...) + dbg.Output(l.calldepth, message) //nolint:errcheck // we can't do anything with what we log +} + +func (l *logger) Infof(message string, args ...interface{}) { + if info == nil || minLevel > INFO { + return + } + message = l.format(message, args...) + info.Output(l.calldepth, message) //nolint:errcheck // we can't do anything with what we log +} + +func (l *logger) Warnf(message string, args ...interface{}) { + if warn == nil || minLevel > WARN { + return + } + message = l.format(message, args...) + warn.Output(l.calldepth, message) //nolint:errcheck // we can't do anything with what we log +} + +func (l *logger) Errorf(message string, args ...interface{}) { + if err == nil || minLevel > ERROR { + return + } + message = l.format(message, args...) + err.Output(l.calldepth, message) //nolint:errcheck // we can't do anything with what we log +} + +var root = logger{calldepth: 3} + +func Tracef(message string, args ...interface{}) { + root.Tracef(message, args...) +} + +func Debugf(message string, args ...interface{}) { + root.Debugf(message, args...) +} + +func Infof(message string, args ...interface{}) { + root.Infof(message, args...) +} + +func Warnf(message string, args ...interface{}) { + root.Warnf(message, args...) +} + +func Errorf(message string, args ...interface{}) { + root.Errorf(message, args...) +} diff --git a/lib/log/panic-logger.go b/lib/log/panic-logger.go new file mode 100644 index 0000000..a190441 --- /dev/null +++ b/lib/log/panic-logger.go @@ -0,0 +1,60 @@ +package log + +import ( + "fmt" + "io" + "os" + "runtime/debug" + "strings" + "time" +) + +var ( + UICleanup = func() {} + BuildInfo string +) + +// PanicHandler tries to restore the terminal. A stack trace is written to +// aerc-crash.log and then passed on if a panic occurs. +func PanicHandler() { + r := recover() + + if r == nil { + return + } + + UICleanup() + + filename := time.Now().Format("/tmp/aerc-crash-20060102-150405.log") + + panicLog, err := os.OpenFile(filename, os.O_SYNC|os.O_APPEND|os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o600) + if err != nil { + // we tried, not possible. bye + panic(r) + } + defer panicLog.Close() + + outputs := io.MultiWriter(panicLog, os.Stderr) + + // if any error happens here, we do not care. + fmt.Fprintln(panicLog, strings.Repeat("#", 80)) + fmt.Fprint(panicLog, strings.Repeat(" ", 34)) + fmt.Fprintln(panicLog, "PANIC CAUGHT!") + fmt.Fprint(panicLog, strings.Repeat(" ", 24)) + fmt.Fprintln(panicLog, time.Now().Format("2006-01-02T15:04:05.000000-0700")) + fmt.Fprintln(panicLog, strings.Repeat("#", 80)) + fmt.Fprintf(outputs, "%s\n", panicMessage) + fmt.Fprintf(outputs, "Version: %s\n", BuildInfo) + fmt.Fprintf(panicLog, "Error: %v\n\n", r) + panicLog.Write(debug.Stack()) //nolint:errcheck // we are already in a panic, so not much we can do here + fmt.Fprintf(os.Stderr, "\nThis error was also written to: %s\n", filename) + panic(r) +} + +const panicMessage = ` +aerc has encountered a critical error and has terminated. Please help us fix +this by sending this log and the steps to reproduce the crash to: +~rjarry/aerc-devel@lists.sr.ht + +Thank you +` diff --git a/lib/marker/marker.go b/lib/marker/marker.go new file mode 100644 index 0000000..4516942 --- /dev/null +++ b/lib/marker/marker.go @@ -0,0 +1,195 @@ +package marker + +import "git.sr.ht/~rjarry/aerc/models" + +// Marker provides the interface for the marking behavior of messages +type Marker interface { + Mark(models.UID) + Unmark(models.UID) + ToggleMark(models.UID) + Remark() + Marked() []models.UID + IsMarked(models.UID) bool + IsVisualMark() bool + ToggleVisualMark(bool) + UpdateVisualMark() + ClearVisualMark() +} + +// UIDProvider provides the underlying uids and the selected message index +type UIDProvider interface { + Uids() []models.UID + SelectedIndex() int +} + +type controller struct { + uidProvider UIDProvider + marked map[models.UID]struct{} + lastMarked map[models.UID]struct{} + visualStartUID models.UID + visualMarkMode bool + visualBase map[models.UID]struct{} +} + +// New returns a new Marker +func New(up UIDProvider) Marker { + return &controller{ + uidProvider: up, + marked: make(map[models.UID]struct{}), + lastMarked: make(map[models.UID]struct{}), + } +} + +// Mark marks the uid as marked +func (mc *controller) Mark(uid models.UID) { + if mc.visualMarkMode { + // visual mode has override, bogus input from user + return + } + mc.marked[uid] = struct{}{} +} + +// Unmark unmarks the uid +func (mc *controller) Unmark(uid models.UID) { + if mc.visualMarkMode { + // user probably wanted to clear the visual marking + mc.ClearVisualMark() + return + } + delete(mc.marked, uid) +} + +// Remark restores the previous marks +func (mc *controller) Remark() { + mc.marked = mc.lastMarked +} + +// ToggleMark toggles the marked state for the given uid +func (mc *controller) ToggleMark(uid models.UID) { + if mc.visualMarkMode { + // visual mode has override, bogus input from user + return + } + if mc.IsMarked(uid) { + mc.Unmark(uid) + } else { + mc.Mark(uid) + } +} + +// resetMark removes the marking from all messages +func (mc *controller) resetMark() { + mc.lastMarked = mc.marked + mc.marked = make(map[models.UID]struct{}) +} + +// removeStaleUID removes uids that are no longer presents in the UIDProvider +func (mc *controller) removeStaleUID() { + for mark := range mc.marked { + present := false + for _, uid := range mc.uidProvider.Uids() { + if mark == uid { + present = true + break + } + } + if !present { + delete(mc.marked, mark) + } + } +} + +// IsMarked checks whether the given uid has been marked +func (mc *controller) IsMarked(uid models.UID) bool { + _, marked := mc.marked[uid] + return marked +} + +// Marked returns the uids of all marked messages +func (mc *controller) Marked() []models.UID { + mc.removeStaleUID() + marked := make([]models.UID, len(mc.marked)) + i := 0 + for uid := range mc.marked { + marked[i] = uid + i++ + } + return marked +} + +// IsVisualMark indicates whether visual marking mode is enabled. +func (mc *controller) IsVisualMark() bool { + return mc.visualMarkMode +} + +// ToggleVisualMark enters or leaves the visual marking mode +func (mc *controller) ToggleVisualMark(clear bool) { + mc.visualMarkMode = !mc.visualMarkMode + if mc.visualMarkMode { + // just entered visual mode, reset whatever marking was already done + if clear { + mc.resetMark() + } + uids := mc.uidProvider.Uids() + if idx := mc.uidProvider.SelectedIndex(); idx >= 0 && idx < len(uids) { + mc.visualStartUID = uids[idx] + mc.marked[mc.visualStartUID] = struct{}{} + mc.visualBase = make(map[models.UID]struct{}) + for key, value := range mc.marked { + mc.visualBase[key] = value + } + } + } +} + +// ClearVisualMark leaves the visual marking mode and resets any marking +func (mc *controller) ClearVisualMark() { + mc.resetMark() + mc.visualMarkMode = false + mc.visualStartUID = "" +} + +// UpdateVisualMark updates the index with the currently selected message +func (mc *controller) UpdateVisualMark() { + if !mc.visualMarkMode { + // nothing to do + return + } + startIdx := mc.visualStartIdx() + if startIdx < 0 { + // something deleted the startuid, abort the marking process + mc.ClearVisualMark() + return + } + + selectedIdx := mc.uidProvider.SelectedIndex() + if selectedIdx < 0 { + return + } + + uids := mc.uidProvider.Uids() + + var visUids []models.UID + if selectedIdx > startIdx { + visUids = uids[startIdx : selectedIdx+1] + } else { + visUids = uids[selectedIdx : startIdx+1] + } + mc.marked = make(map[models.UID]struct{}) + for uid := range mc.visualBase { + mc.marked[uid] = struct{}{} + } + for _, uid := range visUids { + mc.marked[uid] = struct{}{} + } +} + +// returns the index of needle in haystack or -1 if not found +func (mc *controller) visualStartIdx() int { + for idx, u := range mc.uidProvider.Uids() { + if u == mc.visualStartUID { + return idx + } + } + return -1 +} diff --git a/lib/marker/marker_test.go b/lib/marker/marker_test.go new file mode 100644 index 0000000..04d3fed --- /dev/null +++ b/lib/marker/marker_test.go @@ -0,0 +1,140 @@ +package marker_test + +import ( + "testing" + + "git.sr.ht/~rjarry/aerc/lib/marker" + "git.sr.ht/~rjarry/aerc/models" +) + +// mockUidProvider implements the UidProvider interface and mocks the message +// store for testing +type mockUidProvider struct { + uids []models.UID + idx int +} + +func (mock *mockUidProvider) Uids() []models.UID { + return mock.uids +} + +func (mock *mockUidProvider) SelectedIndex() int { + return mock.idx +} + +func createMarker() (marker.Marker, *mockUidProvider) { + uidProvider := &mockUidProvider{ + uids: []models.UID{"1", "2", "3", "4"}, + idx: 1, + } + m := marker.New(uidProvider) + return m, uidProvider +} + +func TestMarker_MarkUnmark(t *testing.T) { + m, _ := createMarker() + uid := models.UID("4") + + m.Mark(uid) + if !m.IsMarked(uid) { + t.Errorf("Marking failed") + } + + m.Unmark(uid) + if m.IsMarked(uid) { + t.Errorf("Unmarking failed") + } +} + +func TestMarker_ToggleMark(t *testing.T) { + m, _ := createMarker() + uid := models.UID("4") + + if m.IsMarked(uid) { + t.Errorf("ToggleMark: uid should not be marked") + } + + m.ToggleMark(uid) + if !m.IsMarked(uid) { + t.Errorf("ToggleMark: uid should be marked") + } + + m.ToggleMark(uid) + if m.IsMarked(uid) { + t.Errorf("ToggleMark: uid should not be marked") + } +} + +func TestMarker_Marked(t *testing.T) { + m, _ := createMarker() + expected := map[models.UID]struct{}{ + "1": {}, + "4": {}, + } + for uid := range expected { + m.Mark(uid) + } + + got := m.Marked() + if len(expected) != len(got) { + t.Errorf("Marked: expected len of %d but got %d", len(expected), len(got)) + } + + for _, uid := range got { + if _, ok := expected[uid]; !ok { + t.Errorf("Marked: received uid %q as marked but it should not be", uid) + } + } +} + +func TestMarker_VisualMode(t *testing.T) { + m, up := createMarker() + + // activate visual mode + m.ToggleVisualMark(false) + + // marking should now fail silently because we're in visual mode + m.Mark("1") + if m.IsMarked("1") { + t.Errorf("marking in visual mode should not work") + } + + // move selection index to last item + up.idx = len(up.uids) - 1 + m.UpdateVisualMark() + expectedMarked := []models.UID{"2", "3", "4"} + + for _, uidMarked := range expectedMarked { + if !m.IsMarked(uidMarked) { + t.Logf("expected: %#v, got: %#v", expectedMarked, m.Marked()) + t.Errorf("updatevisual: uid %v should be marked in visual mode", uidMarked) + } + } + + // clear all + m.ClearVisualMark() + if len(m.Marked()) > 0 { + t.Errorf("no uids should be marked after clearing visual mark") + } + + // test remark + m.Remark() + for _, uidMarked := range expectedMarked { + if !m.IsMarked(uidMarked) { + t.Errorf("remark: uid %v should be marked in visual mode", uidMarked) + } + } +} + +func TestMarker_MarkOutOfBound(t *testing.T) { + m, _ := createMarker() + + outOfBoundUid := models.UID("100") + + m.Mark(outOfBoundUid) + for _, markedUid := range m.Marked() { + if markedUid == outOfBoundUid { + t.Errorf("out-of-bound uid should not be marked") + } + } +} diff --git a/lib/messageview.go b/lib/messageview.go new file mode 100644 index 0000000..624e96b --- /dev/null +++ b/lib/messageview.go @@ -0,0 +1,185 @@ +package lib + +import ( + "bytes" + "fmt" + "io" + "strings" + + "github.com/ProtonMail/go-crypto/openpgp" + _ "github.com/emersion/go-message/charset" + + "git.sr.ht/~rjarry/aerc/lib/crypto" + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/rfc822" + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/types" +) + +// This is an abstraction for viewing a message with semi-transparent PGP +// support. +type MessageView interface { + // Returns the MessageInfo for this message + MessageInfo() *models.MessageInfo + + // Returns the BodyStructure for this message + BodyStructure() *models.BodyStructure + + // Returns the message store that this message was originally sourced from + Store() *MessageStore + + // Fetches the full message + FetchFull(cb func(io.Reader)) + + // Fetches a specific body part for this message + FetchBodyPart(part []int, cb func(io.Reader)) + + MessageDetails() *models.MessageDetails + + // SeenFlagSet returns true if the "seen" flag has been set + SeenFlagSet() bool +} + +func usePGP(info *models.BodyStructure) bool { + if info == nil { + return false + } + if info.MIMEType == "application" { + if info.MIMESubType == "pgp-encrypted" || + info.MIMESubType == "pgp-signature" { + + return true + } + } + for _, part := range info.Parts { + if usePGP(part) { + return true + } + } + return false +} + +type MessageStoreView struct { + messageInfo *models.MessageInfo + messageStore *MessageStore + message []byte + details *models.MessageDetails + bodyStructure *models.BodyStructure + setSeen bool +} + +func NewMessageStoreView(messageInfo *models.MessageInfo, setSeen bool, + store *MessageStore, pgp crypto.Provider, decryptKeys openpgp.PromptFunction, + innerCb func(MessageView, error), +) { + cb := func(msv MessageView, err error) { + if msv != nil && setSeen && err == nil && + !messageInfo.Flags.Has(models.SeenFlag) { + store.Flag([]models.UID{messageInfo.Uid}, models.SeenFlag, true, nil) + } + innerCb(msv, err) + } + + if messageInfo == nil { + // Call nils to the callback, the split view will use this to + // display an empty view + cb(nil, nil) + return + } + + msv := &MessageStoreView{ + messageInfo, store, + nil, nil, messageInfo.BodyStructure, + setSeen, + } + + if usePGP(messageInfo.BodyStructure) { + msv.FetchFull(func(fm io.Reader) { + reader := rfc822.NewCRLFReader(fm) + md, err := pgp.Decrypt(reader, decryptKeys) + if err != nil { + cb(nil, err) + return + } + msv.message, err = io.ReadAll(md.Body) + if err != nil { + cb(nil, err) + return + } + decrypted, err := rfc822.ReadMessage(bytes.NewBuffer(msv.message)) + if err != nil { + cb(nil, err) + return + } + bs, err := rfc822.ParseEntityStructure(decrypted) + if rfc822.IsMultipartError(err) { + log.Warnf("MessageView: %v", err) + bs = rfc822.CreateTextPlainBody() + } else if err != nil { + cb(nil, err) + return + } + msv.bodyStructure = bs + msv.details = md + cb(msv, nil) + }) + } else { + cb(msv, nil) + } +} + +func (msv *MessageStoreView) SeenFlagSet() bool { + return msv.setSeen +} + +func (msv *MessageStoreView) MessageInfo() *models.MessageInfo { + return msv.messageInfo +} + +func (msv *MessageStoreView) BodyStructure() *models.BodyStructure { + return msv.bodyStructure +} + +func (msv *MessageStoreView) Store() *MessageStore { + return msv.messageStore +} + +func (msv *MessageStoreView) MessageDetails() *models.MessageDetails { + return msv.details +} + +func (msv *MessageStoreView) FetchFull(cb func(io.Reader)) { + if msv.message == nil && msv.messageStore != nil { + msv.messageStore.FetchFull([]models.UID{msv.messageInfo.Uid}, + func(fm *types.FullMessage) { + cb(fm.Content.Reader) + }) + return + } + cb(bytes.NewReader(msv.message)) +} + +func (msv *MessageStoreView) FetchBodyPart(part []int, cb func(io.Reader)) { + if msv.message == nil && msv.messageStore != nil { + msv.messageStore.FetchBodyPart(msv.messageInfo.Uid, part, cb) + return + } + + buf := bytes.NewBuffer(msv.message) + msg, err := rfc822.ReadMessage(buf) + if err != nil { + panic(err) + } + reader, err := rfc822.FetchEntityPartReader(msg, part) + if err != nil { + errMsg := fmt.Errorf("Failed to fetch message part: %w", err) + log.Errorf(errMsg.Error()) + if msv.message != nil { + log.Warnf("Displaying raw message part") + reader = bytes.NewReader(msv.message) + } else { + reader = strings.NewReader(errMsg.Error()) + } + } + cb(reader) +} diff --git a/lib/msgstore.go b/lib/msgstore.go new file mode 100644 index 0000000..13015ac --- /dev/null +++ b/lib/msgstore.go @@ -0,0 +1,1039 @@ +package lib + +import ( + "context" + "errors" + "io" + "sync" + "time" + + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/lib/iterator" + "git.sr.ht/~rjarry/aerc/lib/marker" + "git.sr.ht/~rjarry/aerc/lib/sort" + "git.sr.ht/~rjarry/aerc/lib/ui" + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/types" +) + +// Accesses to fields must be guarded by MessageStore.Lock/Unlock +type MessageStore struct { + sync.Mutex + Name string + Deleted map[models.UID]interface{} + Messages map[models.UID]*models.MessageInfo + Sorting bool + + ui func() *config.UIConfig + + // ctx is given by the directory lister + ctx context.Context + + // Ordered list of known UIDs + uids []models.UID + threads []*types.Thread + + // Visible UIDs + scrollOffset int + scrollLen int + + selectedUid models.UID + bodyCallbacks map[models.UID][]func(*types.FullMessage) + + // marking + marker marker.Marker + + // Search/filter results + results []models.UID + resultIndex int + filter *types.SearchCriteria + + sortCriteria []*types.SortCriterion + sortDefault []*types.SortCriterion + + threadedView bool + threadContext bool + buildThreads bool + builder *ThreadBuilder + + directoryContentsLoaded bool + + // Map of uids we've asked the worker to fetch + onUpdate func(store *MessageStore) // TODO: multiple onUpdate handlers + onFilterChange func(store *MessageStore) + onUpdateDirs func() + pendingBodies map[models.UID]interface{} + pendingHeaders map[models.UID]interface{} + worker *types.Worker + + needsFlags []models.UID + fetchFlagsDebounce *time.Timer + fetchFlagsDelay time.Duration + + triggerNewEmail func(*models.MessageInfo) + triggerDirectoryChange func() + triggerMailDeleted func() + triggerMailAdded func(string) + triggerTagModified func([]string, []string) + triggerFlagChanged func(string) + + threadBuilderDebounce *time.Timer + threadCallback func() + + // threads mutex protects the store.threads and store.threadCallback + threadsMutex sync.Mutex + + iterFactory iterator.Factory + onSelect func(*models.MessageInfo) +} + +const MagicUid = models.UID("") + +func NewMessageStore(worker *types.Worker, name string, + ui func() *config.UIConfig, + triggerNewEmail func(*models.MessageInfo), + triggerDirectoryChange func(), triggerMailDeleted func(), + triggerMailAdded func(string), triggerTagModified func([]string, []string), + triggerFlagChanged func(string), + onSelect func(*models.MessageInfo), +) *MessageStore { + return &MessageStore{ + Name: name, + Deleted: make(map[models.UID]interface{}), + Messages: make(map[models.UID]*models.MessageInfo), + + ui: ui, + + ctx: context.Background(), + + selectedUid: MagicUid, + // default window height until account is drawn once + scrollLen: 25, + + bodyCallbacks: make(map[models.UID][]func(*types.FullMessage)), + pendingBodies: make(map[models.UID]interface{}), + pendingHeaders: make(map[models.UID]interface{}), + worker: worker, + + needsFlags: []models.UID{}, + fetchFlagsDelay: 50 * time.Millisecond, + + triggerNewEmail: triggerNewEmail, + triggerDirectoryChange: triggerDirectoryChange, + triggerMailDeleted: triggerMailDeleted, + triggerMailAdded: triggerMailAdded, + triggerTagModified: triggerTagModified, + triggerFlagChanged: triggerFlagChanged, + + onSelect: onSelect, + } +} + +func (store *MessageStore) Configure( + defaultSort []*types.SortCriterion, +) { + uiConf := store.ui() + + store.buildThreads = uiConf.ForceClientThreads || + !store.worker.Backend.Capabilities().Thread + store.iterFactory = iterator.NewFactory(uiConf.ReverseOrder) + + // The following config values can be toggled by the user; + // reset to default values when reloading config + store.threadedView = uiConf.ThreadingEnabled + store.threadContext = uiConf.ThreadContext + + // update the default sort criteria + store.sortDefault = defaultSort + if store.sortCriteria == nil { + store.sortCriteria = defaultSort + } +} + +func (store *MessageStore) SetContext(ctx context.Context) { + store.ctx = ctx +} + +func (store *MessageStore) UpdateScroll(offset, length int) { + store.scrollOffset = offset + store.scrollLen = length +} + +func (store *MessageStore) FetchHeaders(uids []models.UID, + cb func(types.WorkerMessage), +) { + // TODO: this could be optimized by pre-allocating toFetch and trimming it + // at the end. In practice we expect to get most messages back in one frame. + var toFetch []models.UID + for _, uid := range uids { + if _, ok := store.pendingHeaders[uid]; !ok { + toFetch = append(toFetch, uid) + store.pendingHeaders[uid] = nil + } + } + if len(toFetch) > 0 { + store.worker.PostAction(&types.FetchMessageHeaders{ + Context: store.ctx, + Uids: toFetch, + }, + func(msg types.WorkerMessage) { + switch msg.(type) { + case *types.Error, *types.Done, *types.Cancelled: + for _, uid := range toFetch { + delete(store.pendingHeaders, uid) + } + } + if cb != nil { + cb(msg) + } + }) + } +} + +func (store *MessageStore) FetchFull(uids []models.UID, cb func(*types.FullMessage)) { + // TODO: this could be optimized by pre-allocating toFetch and trimming it + // at the end. In practice we expect to get most messages back in one frame. + var toFetch []models.UID + for _, uid := range uids { + if _, ok := store.pendingBodies[uid]; !ok { + toFetch = append(toFetch, uid) + store.pendingBodies[uid] = nil + if cb != nil { + if list, ok := store.bodyCallbacks[uid]; ok { + store.bodyCallbacks[uid] = append(list, cb) + } else { + store.bodyCallbacks[uid] = []func(*types.FullMessage){cb} + } + } + } + } + if len(toFetch) > 0 { + store.worker.PostAction(&types.FetchFullMessages{ + Uids: toFetch, + }, func(msg types.WorkerMessage) { + if _, ok := msg.(*types.Error); ok { + for _, uid := range toFetch { + delete(store.pendingBodies, uid) + delete(store.bodyCallbacks, uid) + } + } + }) + } +} + +func (store *MessageStore) FetchBodyPart(uid models.UID, part []int, cb func(io.Reader)) { + store.worker.PostAction(&types.FetchMessageBodyPart{ + Uid: uid, + Part: part, + }, func(resp types.WorkerMessage) { + msg, ok := resp.(*types.MessageBodyPart) + if !ok { + return + } + cb(msg.Part.Reader) + }) +} + +func merge(to *models.MessageInfo, from *models.MessageInfo) { + if from.BodyStructure != nil { + to.BodyStructure = from.BodyStructure + } + if from.Envelope != nil { + to.Envelope = from.Envelope + } + to.Flags = from.Flags + to.Labels = from.Labels + to.Error = from.Error + if from.Size != 0 { + to.Size = from.Size + } + var zero time.Time + if from.InternalDate != zero { + to.InternalDate = from.InternalDate + } +} + +func (store *MessageStore) Update(msg types.WorkerMessage) { + var newUids []models.UID + update := false + updateThreads := false + directoryChange := false + directoryContentsWasLoaded := store.directoryContentsLoaded + start := store.scrollOffset + end := store.scrollOffset + store.scrollLen + + switch msg := msg.(type) { + case *types.OpenDirectory: + store.Sort(store.sortCriteria, nil) + update = true + case *types.DirectoryContents: + newMap := make(map[models.UID]*models.MessageInfo, len(msg.Uids)) + for i, uid := range msg.Uids { + if msg, ok := store.Messages[uid]; ok { + newMap[uid] = msg + } else { + newMap[uid] = nil + directoryChange = true + if i >= start && i < end { + newUids = append(newUids, uid) + } + } + } + store.Messages = newMap + store.uids = msg.Uids + if store.threadedView { + store.runThreadBuilderNow() + } + store.directoryContentsLoaded = true + case *types.DirectoryThreaded: + if store.builder == nil { + store.builder = NewThreadBuilder(store.iterFactory, + store.ui().ThreadingBySubject) + } + store.builder.RebuildUids(msg.Threads, store.ReverseThreadOrder()) + store.uids = store.builder.Uids() + store.threads = msg.Threads + + newMap := make(map[models.UID]*models.MessageInfo, len(store.uids)) + for i, uid := range store.uids { + if msg, ok := store.Messages[uid]; ok { + newMap[uid] = msg + } else { + newMap[uid] = nil + directoryChange = true + if i >= start && i < end { + newUids = append(newUids, uid) + } + } + } + + store.Messages = newMap + update = true + store.directoryContentsLoaded = true + case *types.MessageInfo: + infoUpdated := msg.Info.Envelope != nil || msg.Info.Error != nil + if existing, ok := store.Messages[msg.Info.Uid]; ok && existing != nil { + merge(existing, msg.Info) + } else if infoUpdated { + store.Messages[msg.Info.Uid] = msg.Info + if store.selectedUid == msg.Info.Uid { + store.onSelect(msg.Info) + } + } + if msg.NeedsFlags { + store.Lock() + store.needsFlags = append(store.needsFlags, msg.Info.Uid) + store.Unlock() + store.fetchFlags() + } + seen := msg.Info.Flags.Has(models.SeenFlag) + recent := msg.Info.Flags.Has(models.RecentFlag) + if !seen && recent && msg.Info.Envelope != nil { + store.triggerNewEmail(msg.Info) + } + if _, ok := store.pendingHeaders[msg.Info.Uid]; infoUpdated && ok { + delete(store.pendingHeaders, msg.Info.Uid) + } + if store.builder != nil { + store.builder.Update(msg.Info) + } + update = true + updateThreads = true + case *types.FullMessage: + if _, ok := store.pendingBodies[msg.Content.Uid]; ok { + delete(store.pendingBodies, msg.Content.Uid) + if cbs, ok := store.bodyCallbacks[msg.Content.Uid]; ok { + for _, cb := range cbs { + cb(msg) + } + delete(store.bodyCallbacks, msg.Content.Uid) + } + } + case *types.MessagesDeleted: + if len(store.uids) < len(msg.Uids) { + update = true + break + } + + toDelete := make(map[models.UID]interface{}) + for _, uid := range msg.Uids { + toDelete[uid] = nil + delete(store.Messages, uid) + delete(store.Deleted, uid) + } + uids := make([]models.UID, 0, len(store.uids)-len(msg.Uids)) + for _, uid := range store.uids { + if _, deleted := toDelete[uid]; deleted { + continue + } + uids = append(uids, uid) + } + store.uids = uids + if len(uids) == 0 { + store.Select(MagicUid) + } + + var newResults []models.UID + for _, res := range store.results { + if _, deleted := toDelete[res]; !deleted { + newResults = append(newResults, res) + } + } + store.results = newResults + + for uid := range toDelete { + thread, err := store.Thread(uid) + if err != nil { + continue + } + thread.Deleted = true + } + + update = true + updateThreads = true + } + + if update { + store.update(updateThreads) + } + + if directoryContentsWasLoaded && directoryChange && store.triggerDirectoryChange != nil { + store.triggerDirectoryChange() + } + + if len(newUids) > 0 { + store.FetchHeaders(newUids, nil) + if directoryContentsWasLoaded && store.triggerDirectoryChange != nil { + store.triggerDirectoryChange() + } + } +} + +func (store *MessageStore) OnUpdate(fn func(store *MessageStore)) { + store.onUpdate = fn +} + +func (store *MessageStore) OnFilterChange(fn func(store *MessageStore)) { + store.onFilterChange = fn +} + +func (store *MessageStore) OnUpdateDirs(fn func()) { + store.onUpdateDirs = fn +} + +func (store *MessageStore) update(threads bool) { + if store.onUpdate != nil { + store.onUpdate(store) + } + if store.onUpdateDirs != nil { + store.onUpdateDirs() + } + if store.ThreadedView() && threads { + switch { + case store.BuildThreads(): + store.runThreadBuilder() + default: + if store.builder == nil { + store.builder = NewThreadBuilder(store.iterFactory, + store.ui().ThreadingBySubject) + } + store.threadsMutex.Lock() + store.builder.RebuildUids(store.threads, + store.ReverseThreadOrder()) + store.threadsMutex.Unlock() + } + } +} + +func (store *MessageStore) ReverseThreadOrder() bool { + return store.ui().ReverseThreadOrder +} + +func (store *MessageStore) SetThreadedView(thread bool) { + store.threadedView = thread + if store.buildThreads { + if store.threadedView { + store.runThreadBuilder() + } else if store.threadBuilderDebounce != nil { + store.threadBuilderDebounce.Stop() + } + return + } + store.Sort(store.sortCriteria, nil) +} + +func (store *MessageStore) ThreadsIterator() iterator.Iterator { + store.threadsMutex.Lock() + defer store.threadsMutex.Unlock() + return store.iterFactory.NewIterator(store.threads) +} + +func (store *MessageStore) ThreadedView() bool { + return store.threadedView +} + +func (store *MessageStore) ToggleThreadContext() { + if !store.threadedView { + return + } + store.threadContext = !store.threadContext + store.Sort(store.sortCriteria, nil) +} + +func (store *MessageStore) BuildThreads() bool { + return store.buildThreads +} + +func (store *MessageStore) runThreadBuilder() { + if store.builder == nil { + store.builder = NewThreadBuilder(store.iterFactory, + store.ui().ThreadingBySubject) + for _, msg := range store.Messages { + store.builder.Update(msg) + } + } + if store.threadBuilderDebounce != nil { + store.threadBuilderDebounce.Stop() + } + store.threadBuilderDebounce = time.AfterFunc(store.ui().ClientThreadsDelay, + func() { + store.runThreadBuilderNow() + ui.Invalidate() + }, + ) +} + +// runThreadBuilderNow runs the threadbuilder without any debounce logic +func (store *MessageStore) runThreadBuilderNow() { + if store.builder == nil { + store.builder = NewThreadBuilder(store.iterFactory, + store.ui().ThreadingBySubject) + for _, msg := range store.Messages { + store.builder.Update(msg) + } + } + // build new threads + th := store.builder.Threads(store.uids, store.ReverseThreadOrder(), + store.ui().SortThreadSiblings) + + // save local threads to the message store variable and + // run callback if defined (callback should reposition cursor) + store.threadsMutex.Lock() + store.threads = th + if store.threadCallback != nil { + store.threadCallback() + } + store.threadsMutex.Unlock() + + // invalidate message list + if store.onUpdate != nil { + store.onUpdate(store) + } +} + +// Thread returns the thread for the given UId +func (store *MessageStore) Thread(uid models.UID) (*types.Thread, error) { + if store.builder == nil { + return nil, errors.New("no threads found") + } + return store.builder.ThreadForUid(uid) +} + +// SelectedThread returns the thread with the UID from the selected message +func (store *MessageStore) SelectedThread() (*types.Thread, error) { + return store.Thread(store.SelectedUid()) +} + +func (store *MessageStore) Fold(uid models.UID, toggle bool) error { + return store.doThreadFolding(uid, true, toggle) +} + +func (store *MessageStore) Unfold(uid models.UID, toggle bool) error { + return store.doThreadFolding(uid, false, toggle) +} + +func (store *MessageStore) doThreadFolding(uid models.UID, hide bool, toggle bool) error { + thread, err := store.Thread(uid) + if err != nil { + return err + } + if len(thread.Uids()) == 1 { + return nil + } + folded := thread.FirstChild.Hidden > 0 + if !toggle && hide && folded { + return nil + } + err = thread.Walk(func(t *types.Thread, _ int, __ error) error { + if t.Uid != uid { + switch { + case toggle: + if folded { + if t.Hidden > 1 { + t.Hidden-- + } else { + t.Hidden = 0 + } + } else { + t.Hidden++ + } + case hide: + t.Hidden++ + case t.Hidden > 1: + t.Hidden-- + default: + t.Hidden = 0 + } + } + return nil + }) + if err != nil { + return err + } + if store.builder == nil { + return errors.New("No thread builder available") + } + store.Select(uid) + store.threadsMutex.Lock() + store.builder.RebuildUids(store.threads, store.ReverseThreadOrder()) + store.threadsMutex.Unlock() + return nil +} + +func (store *MessageStore) Delete(uids []models.UID, mfs *types.MultiFileStrategy, + cb func(msg types.WorkerMessage), +) { + for _, uid := range uids { + store.Deleted[uid] = nil + } + + store.worker.PostAction(&types.DeleteMessages{Uids: uids, MultiFileStrategy: mfs}, + func(msg types.WorkerMessage) { + if _, ok := msg.(*types.Error); ok { + store.revertDeleted(uids) + } + if _, ok := msg.(*types.Unsupported); ok { + store.revertDeleted(uids) + } + if _, ok := msg.(*types.Done); ok { + store.triggerMailDeleted() + } + cb(msg) + }) +} + +func (store *MessageStore) revertDeleted(uids []models.UID) { + for _, uid := range uids { + delete(store.Deleted, uid) + } +} + +func (store *MessageStore) Copy(uids []models.UID, dest string, createDest bool, + mfs *types.MultiFileStrategy, cb func(msg types.WorkerMessage), +) { + if createDest { + store.worker.PostAction(&types.CreateDirectory{ + Directory: dest, + Quiet: true, + }, cb) + } + + store.worker.PostAction(&types.CopyMessages{ + Destination: dest, + Uids: uids, + MultiFileStrategy: mfs, + }, func(msg types.WorkerMessage) { + if _, ok := msg.(*types.Done); ok { + store.triggerMailAdded(dest) + } + cb(msg) + }) +} + +func (store *MessageStore) Move(uids []models.UID, dest string, createDest bool, + mfs *types.MultiFileStrategy, cb func(msg types.WorkerMessage), +) { + for _, uid := range uids { + store.Deleted[uid] = nil + } + + if createDest { + store.worker.PostAction(&types.CreateDirectory{ + Directory: dest, + Quiet: true, + }, nil) // quiet doesn't return an error, don't want the done cb here + } + + store.worker.PostAction(&types.MoveMessages{ + Destination: dest, + Uids: uids, + MultiFileStrategy: mfs, + }, func(msg types.WorkerMessage) { + switch msg.(type) { + case *types.Error: + store.revertDeleted(uids) + cb(msg) + case *types.Done: + store.triggerMailDeleted() + store.triggerMailAdded(dest) + cb(msg) + } + }) +} + +func (store *MessageStore) Append(dest string, flags models.Flags, date time.Time, + reader io.Reader, length int, cb func(msg types.WorkerMessage), +) { + store.worker.PostAction(&types.CreateDirectory{ + Directory: dest, + Quiet: true, + }, nil) + + store.worker.PostAction(&types.AppendMessage{ + Destination: dest, + Flags: flags, + Date: date, + Reader: reader, + Length: length, + }, func(msg types.WorkerMessage) { + if _, ok := msg.(*types.Done); ok { + store.triggerMailAdded(dest) + } + cb(msg) + }) +} + +func (store *MessageStore) Flag(uids []models.UID, flags models.Flags, + enable bool, cb func(msg types.WorkerMessage), +) { + store.worker.PostAction(&types.FlagMessages{ + Enable: enable, + Flags: flags, + Uids: uids, + }, func(msg types.WorkerMessage) { + var flagName string + switch flags { + case models.SeenFlag: + flagName = "seen" + case models.AnsweredFlag: + flagName = "answered" + case models.ForwardedFlag: + flagName = "forwarded" + case models.FlaggedFlag: + flagName = "flagged" + case models.DraftFlag: + flagName = "draft" + } + if _, ok := msg.(*types.Done); ok { + store.triggerFlagChanged(flagName) + } + if cb != nil { + cb(msg) + } + }) +} + +func (store *MessageStore) Answered(uids []models.UID, answered bool, + cb func(msg types.WorkerMessage), +) { + store.worker.PostAction(&types.AnsweredMessages{ + Answered: answered, + Uids: uids, + }, cb) +} + +func (store *MessageStore) Forwarded(uids []models.UID, forwarded bool, + cb func(msg types.WorkerMessage), +) { + store.worker.PostAction(&types.ForwardedMessages{ + Forwarded: forwarded, + Uids: uids, + }, cb) +} + +func (store *MessageStore) Uids() []models.UID { + if store.ThreadedView() && store.builder != nil { + if uids := store.builder.Uids(); len(uids) > 0 { + return uids + } + } + return store.uids +} + +func (store *MessageStore) UidsIterator() iterator.Iterator { + return store.iterFactory.NewIterator(store.Uids()) +} + +func (store *MessageStore) Selected() *models.MessageInfo { + return store.Messages[store.selectedUid] +} + +func (store *MessageStore) SelectedUid() models.UID { + if store.selectedUid == MagicUid && len(store.Uids()) > 0 { + iter := store.UidsIterator() + idx := iter.StartIndex() + if store.ui().SelectLast { + idx = iter.EndIndex() + } + store.Select(store.Uids()[idx]) + } + return store.selectedUid +} + +func (store *MessageStore) Select(uid models.UID) { + store.selectPriv(uid, false) + if store.onSelect != nil { + store.onSelect(store.Selected()) + } +} + +func (store *MessageStore) selectPriv(uid models.UID, lockHeld bool) { + if !lockHeld { + store.threadsMutex.Lock() + } + if store.threadCallback != nil { + store.threadCallback = nil + } + if !lockHeld { + store.threadsMutex.Unlock() + } + store.selectedUid = uid + if store.marker != nil { + store.marker.UpdateVisualMark() + } +} + +func (store *MessageStore) NextPrev(delta int) { + uids := store.Uids() + if len(uids) == 0 { + return + } + + iter := store.iterFactory.NewIterator(uids) + + newIdx := store.FindIndexByUid(store.SelectedUid()) + if newIdx < 0 { + store.Select(uids[iter.StartIndex()]) + return + } + newIdx = iterator.MoveIndex( + newIdx, + delta, + iter, + iterator.FixBounds, + ) + store.Select(uids[newIdx]) + + if store.BuildThreads() && store.ThreadedView() { + store.threadsMutex.Lock() + store.threadCallback = func() { + if uids := store.Uids(); len(uids) > newIdx { + store.selectPriv(uids[newIdx], true) + } + } + store.threadsMutex.Unlock() + } + + if store.marker != nil { + store.marker.UpdateVisualMark() + } + store.updateResults() +} + +func (store *MessageStore) Next() { + store.NextPrev(1) +} + +func (store *MessageStore) Prev() { + store.NextPrev(-1) +} + +func (store *MessageStore) Search(terms *types.SearchCriteria, cb func([]models.UID)) { + store.worker.PostAction(&types.SearchDirectory{ + Context: store.ctx, + Criteria: terms, + }, func(msg types.WorkerMessage) { + if msg, ok := msg.(*types.SearchResults); ok { + allowedUids := store.Uids() + uids := make([]models.UID, 0, len(msg.Uids)) + for _, uid := range msg.Uids { + for _, uidCheck := range allowedUids { + if uid == uidCheck { + uids = append(uids, uid) + break + } + } + } + sort.SortBy(uids, allowedUids) + cb(uids) + } + }) +} + +func (store *MessageStore) ApplySearch(results []models.UID) { + store.results = results + store.resultIndex = -1 + store.NextResult() +} + +// IsResult returns true if uid is a search result +func (store *MessageStore) IsResult(uid models.UID) bool { + for _, hit := range store.results { + if hit == uid { + return true + } + } + return false +} + +func (store *MessageStore) SetFilter(terms *types.SearchCriteria) { + store.filter = store.filter.Combine(terms) +} + +func (store *MessageStore) ApplyClear() { + store.filter = nil + store.results = nil + if store.onFilterChange != nil { + store.onFilterChange(store) + } + store.Sort(store.sortDefault, nil) +} + +func (store *MessageStore) updateResults() { + if len(store.results) == 0 || store.resultIndex < 0 { + return + } + uid := store.SelectedUid() + for i, u := range store.results { + if uid == u { + store.resultIndex = i + break + } + } +} + +func (store *MessageStore) nextPrevResult(delta int) { + if len(store.results) == 0 { + return + } + iter := store.iterFactory.NewIterator(store.results) + if store.resultIndex < 0 { + store.resultIndex = iter.StartIndex() + } else { + store.resultIndex = iterator.MoveIndex( + store.resultIndex, + delta, + iter, + iterator.WrapBounds, + ) + } + store.Select(store.results[store.resultIndex]) + store.update(false) +} + +func (store *MessageStore) NextResult() { + store.nextPrevResult(1) +} + +func (store *MessageStore) PrevResult() { + store.nextPrevResult(-1) +} + +func (store *MessageStore) ModifyLabels(uids []models.UID, add, remove []string, + cb func(msg types.WorkerMessage), +) { + store.worker.PostAction(&types.ModifyLabels{ + Uids: uids, + Add: add, + Remove: remove, + }, func(msg types.WorkerMessage) { + if _, ok := msg.(*types.Done); ok { + store.triggerTagModified(add, remove) + } + cb(msg) + }) +} + +func (store *MessageStore) Sort(criteria []*types.SortCriterion, cb func(types.WorkerMessage)) { + store.sortCriteria = criteria + store.Sorting = true + + idx := len(store.Uids()) - (store.SelectedIndex() + 1) + handle_return := func(msg types.WorkerMessage) { + store.Select(store.SelectedUid()) + if store.SelectedIndex() < 0 { + store.Select(MagicUid) + store.NextPrev(idx) + } + store.Sorting = false + if cb != nil { + cb(msg) + } + } + + if store.threadedView && !store.buildThreads { + store.worker.PostAction(&types.FetchDirectoryThreaded{ + Context: store.ctx, + SortCriteria: criteria, + Filter: store.filter, + ThreadContext: store.threadContext, + }, handle_return) + } else { + store.worker.PostAction(&types.FetchDirectoryContents{ + Context: store.ctx, + SortCriteria: criteria, + Filter: store.filter, + }, handle_return) + } +} + +func (store *MessageStore) GetCurrentSortCriteria() []*types.SortCriterion { + return store.sortCriteria +} + +func (store *MessageStore) SetMarker(m marker.Marker) { + store.marker = m +} + +func (store *MessageStore) Marker() marker.Marker { + if store.marker == nil { + store.marker = marker.New(store) + } + return store.marker +} + +// FindIndexByUid returns the index in store.Uids() or -1 if not found +func (store *MessageStore) FindIndexByUid(uid models.UID) int { + for idx, u := range store.Uids() { + if u == uid { + return idx + } + } + return -1 +} + +// Capabilities returns a models.Capabilities struct or nil if not available +func (store *MessageStore) Capabilities() *models.Capabilities { + return store.worker.Backend.Capabilities() +} + +// SelectedIndex returns the index of the selected message in the uid list or +// -1 if not found +func (store *MessageStore) SelectedIndex() int { + return store.FindIndexByUid(store.selectedUid) +} + +func (store *MessageStore) fetchFlags() { + if store.fetchFlagsDebounce != nil { + store.fetchFlagsDebounce.Stop() + } + store.fetchFlagsDebounce = time.AfterFunc(store.fetchFlagsDelay, func() { + store.Lock() + store.worker.PostAction(&types.FetchMessageFlags{ + Context: store.ctx, + Uids: store.needsFlags, + }, nil) + store.needsFlags = []models.UID{} + store.Unlock() + }) +} diff --git a/lib/notmuch/database.go b/lib/notmuch/database.go new file mode 100644 index 0000000..046d5d1 --- /dev/null +++ b/lib/notmuch/database.go @@ -0,0 +1,314 @@ +//go:build notmuch +// +build notmuch + +package notmuch + +/* +#cgo LDFLAGS: -lnotmuch + +#include <stdlib.h> +#include <notmuch.h> + +*/ +import "C" + +import ( + "errors" + "fmt" + "unsafe" +) + +type Mode int + +const ( + MODE_READ_ONLY Mode = C.NOTMUCH_DATABASE_MODE_READ_ONLY + MODE_READ_WRITE Mode = C.NOTMUCH_DATABASE_MODE_READ_WRITE +) + +type Database struct { + // The path to the notmuch database. If Path is the empty string, the + // location will be found in the following order: + // + // 1. The value of the environment variable NOTMUCH_DATABASE + // 2. From the config file specified by Config + // 3. From the Profile specified by profile, given by + // $XDG_DATA_HOME/notmuch/$PROFILE + Path string + + // The path to the notmuch configuration file to use. + Config string + + // If FindConfig is true, libnotmuch will attempt to locate a suitable + // configuration file in the following order: + // + // 1. The value of the environment variable NOTMUCH_CONFIG + // 2. $XDG_CONFIG_HOME/notmuch/ + // 3. $HOME/.notmuch-config + // + // If not configuration file is found, a STATUS_NO_CONFIG error will be + // returned + FindConfig bool + + // The profile to use. If Profile is non-empty, the value will be + // appended to the paths determined for Config and Path. If Profile is + // the empty string, the profile will be determined in the following + // order: + // + // 1. The value of the environment variable NOTMUCH_PROFILE + // 2. "default" if Config and/or Path are a directory, "" if they are a + // filepath + Profile string + + db *C.notmuch_database_t + open bool +} + +// Create creates a notmuch database at the Path +func (db *Database) Create() error { + var cdb *C.notmuch_database_t + var cPath *C.char + defer C.free(unsafe.Pointer(cPath)) + if db.Path != "" { + cPath = C.CString(db.Path) + } + err := errorWrap(C.notmuch_database_create(cPath, &cdb)) //nolint:gocritic // see note in notmuch.go + if err != nil { + return err + } + db.db = cdb + return nil +} + +// Open opens the database with the given mode. Caller must call Close when done +// to commit changes and free resources +func (db *Database) Open(mode Mode) error { + var ( + cPath *C.char + cConfig *C.char + cProfile *C.char + cErr *C.char + ) + defer C.free(unsafe.Pointer(cPath)) + defer C.free(unsafe.Pointer(cConfig)) + defer C.free(unsafe.Pointer(cProfile)) + defer C.free(unsafe.Pointer(cErr)) + + if db.Path != "" { + cPath = C.CString(db.Path) + } + + if !db.FindConfig { + cConfig = C.CString(db.Config) + } + + if db.Profile != "" { + cProfile = C.CString(db.Profile) + } + cmode := C.notmuch_database_mode_t(mode) + + var cdb *C.notmuch_database_t + + // gocritic:dupSubExpr throws an issue here no matter how we call this + // function + err := errorWrap( + C.notmuch_database_open_with_config( + cPath, cmode, cConfig, cProfile, &cdb, &cErr, //nolint:gocritic // see above + ), + ) + if err != nil { + return err + } + db.db = cdb + db.open = true + return nil +} + +// Reopen an open notmuch database, usually with a different mode +func (db *Database) Reopen(mode Mode) error { + cmode := C.notmuch_database_mode_t(mode) + return errorWrap(C.notmuch_database_reopen(db.db, cmode)) +} + +// Close commits changes and closes the database, freeing any resources +// associated with it +func (db *Database) Close() error { + if !db.open { + return nil + } + err := errorWrap(C.notmuch_database_close(db.db)) + if err != nil { + return err + } + err = errorWrap(C.notmuch_database_destroy(db.db)) + if err != nil { + return err + } + db.open = false + return nil +} + +// LastStatus returns the last status string for the database +func (db *Database) LastStatus() string { + cStatus := C.notmuch_database_status_string(db.db) + defer C.free(unsafe.Pointer(cStatus)) + return C.GoString(cStatus) +} + +func (db *Database) Compact(backupPath string) error { + if backupPath == "" { + return fmt.Errorf("must have backup path before compacting") + } + var cBackupPath *C.char + defer C.free(unsafe.Pointer(cBackupPath)) + return errorWrap(C.notmuch_database_compact_db(db.db, cBackupPath, nil, nil)) +} + +// Return the resolved path to the notmuch database +func (db *Database) ResolvedPath() string { + cPath := C.notmuch_database_get_path(db.db) + return C.GoString(cPath) +} + +// NeedsUpgrade reports if the database must be upgraded before a write +// operation can be safely performed +func (db *Database) NeedsUpgrade() bool { + return C.notmuch_database_needs_upgrade(db.db) == 1 +} + +// Indicate the beginning of an atomic operation +func (db *Database) BeginAtomic() error { + return errorWrap(C.notmuch_database_begin_atomic(db.db)) +} + +// Indicate the end of an atomic operation +func (db *Database) EndAtomic() error { + return errorWrap(C.notmuch_database_end_atomic(db.db)) +} + +// Returns the UUID and LastMod of the notmuch database +func (db *Database) Revision() (string, uint64) { + var uuid *C.char + defer C.free(unsafe.Pointer(uuid)) + lastmod := uint64(C.notmuch_database_get_revision(db.db, &uuid)) //nolint:gocritic // see note in notmuch.go + return C.GoString(uuid), lastmod +} + +// Returns a Directory object relative to the path of the Database +func (db *Database) Directory(relativePath string) (Directory, error) { + var result Directory + + if relativePath == "" { + return result, fmt.Errorf("path can't be empty") + } + var ( + dir *C.notmuch_directory_t + cPath *C.char + ) + cPath = C.CString(relativePath) + defer C.free(unsafe.Pointer(cPath)) + err := errorWrap(C.notmuch_database_get_directory(db.db, cPath, &dir)) //nolint:gocritic // see note in notmuch.go + if err != nil { + return result, err + } + result.dir = dir + + return result, nil +} + +// IndexFile indexes a file with path relative to the database path, or an +// absolute path which share a common ancestor as the database path +func (db *Database) IndexFile(path string) (Message, error) { + var ( + cPath *C.char + msg *C.notmuch_message_t + ) + cPath = C.CString(path) + defer C.free(unsafe.Pointer(cPath)) + + err := errorWrap(C.notmuch_database_index_file(db.db, cPath, nil, &msg)) //nolint:gocritic // see note in notmuch.go + switch { + case errors.Is(err, STATUS_DUPLICATE_MESSAGE_ID): + break + case err != nil: + return Message{}, err + } + message := Message{ + message: msg, + } + return message, nil +} + +// Remove a file from the database. If this is the last file associated with a +// message, the message will be removed from the database. +func (db *Database) RemoveFile(path string) error { + cPath := C.CString(path) + defer C.free(unsafe.Pointer(cPath)) + return errorWrap(C.notmuch_database_remove_message(db.db, cPath)) +} + +// FindMessageByID finds a message by the Message-ID header field value +func (db *Database) FindMessageByID(id string) (Message, error) { + var ( + cID *C.char + msg *C.notmuch_message_t + ) + cID = C.CString(id) + defer C.free(unsafe.Pointer(cID)) + err := errorWrap(C.notmuch_database_find_message(db.db, cID, &msg)) //nolint:gocritic // see note in notmuch.go + if err != nil { + return Message{}, err + } + message := Message{ + message: msg, + } + return message, nil +} + +// FindMessageByFilename finds a message by filename +func (db *Database) FindMessageByFilename(filename string) (Message, error) { + var ( + cFilename *C.char + msg *C.notmuch_message_t + ) + cFilename = C.CString(filename) + defer C.free(unsafe.Pointer(cFilename)) + err := errorWrap(C.notmuch_database_find_message_by_filename(db.db, cFilename, &msg)) //nolint:gocritic // see note in notmuch.go + if err != nil { + return Message{}, err + } + if msg == nil { + return Message{}, fmt.Errorf("couldn't find message by filename: %s", filename) + } + message := Message{ + message: msg, + } + return message, nil +} + +// Tags returns a slice of all tags in the database +func (db *Database) Tags() []string { + cTags := C.notmuch_database_get_all_tags(db.db) + defer C.notmuch_tags_destroy(cTags) + + tags := []string{} + for C.notmuch_tags_valid(cTags) > 0 { + tag := C.notmuch_tags_get(cTags) + tags = append(tags, C.GoString(tag)) + C.notmuch_tags_move_to_next(cTags) + } + return tags +} + +// Create a new Query +func (db *Database) Query(query string) (Query, error) { + cQuery := C.CString(query) + defer C.free(unsafe.Pointer(cQuery)) + nmQuery := C.notmuch_query_create(db.db, cQuery) + if nmQuery == nil { + return Query{}, STATUS_OUT_OF_MEMORY + } + q := Query{ + query: nmQuery, + } + return q, nil +} diff --git a/lib/notmuch/directory.go b/lib/notmuch/directory.go new file mode 100644 index 0000000..796c66e --- /dev/null +++ b/lib/notmuch/directory.go @@ -0,0 +1,64 @@ +//go:build notmuch +// +build notmuch + +package notmuch + +/* +#cgo LDFLAGS: -lnotmuch + +#include <notmuch.h> + +*/ +import "C" +import "time" + +type Directory struct { + dir *C.notmuch_directory_t +} + +func (dir *Directory) SetModifiedTime(t time.Time) error { + cTime := C.time_t(t.Unix()) + return errorWrap(C.notmuch_directory_set_mtime(dir.dir, cTime)) +} + +func (dir *Directory) ModifiedTime() time.Time { + cTime := C.notmuch_directory_get_mtime(dir.dir) + return time.Unix(int64(cTime), 0) +} + +func (dir *Directory) Filenames() []string { + cFilenames := C.notmuch_directory_get_child_files(dir.dir) + defer C.notmuch_filenames_destroy(cFilenames) + + filenames := []string{} + for C.notmuch_filenames_valid(cFilenames) > 0 { + filename := C.notmuch_filenames_get(cFilenames) + filenames = append(filenames, C.GoString(filename)) + C.notmuch_filenames_move_to_next(cFilenames) + } + return filenames +} + +func (dir *Directory) Directories() []string { + cFilenames := C.notmuch_directory_get_child_directories(dir.dir) + defer C.notmuch_filenames_destroy(cFilenames) + + filenames := []string{} + for C.notmuch_filenames_valid(cFilenames) > 0 { + filename := C.notmuch_filenames_get(cFilenames) + filenames = append(filenames, C.GoString(filename)) + C.notmuch_filenames_move_to_next(cFilenames) + } + return filenames +} + +// Delete deletes a directory document from the database and destroys +// the underlying object. Any child directories and files must have been +// deleted firs the caller +func (dir *Directory) Delete() error { + return errorWrap(C.notmuch_directory_delete(dir.dir)) +} + +func (dir *Directory) Close() { + C.notmuch_directory_destroy(dir.dir) +} diff --git a/lib/notmuch/errors.go b/lib/notmuch/errors.go new file mode 100644 index 0000000..1b64163 --- /dev/null +++ b/lib/notmuch/errors.go @@ -0,0 +1,55 @@ +//go:build notmuch +// +build notmuch + +package notmuch + +/* +#cgo LDFLAGS: -lnotmuch + +#include <notmuch.h> + +*/ +import "C" + +// Status codes used for the return values of most functions +type Status int + +const ( + STATUS_SUCCESS Status = C.NOTMUCH_STATUS_SUCCESS + STATUS_OUT_OF_MEMORY Status = C.NOTMUCH_STATUS_OUT_OF_MEMORY + STATUS_READ_ONLY_DATABASE Status = C.NOTMUCH_STATUS_READ_ONLY_DATABASE + STATUS_XAPIAN_EXCEPTION Status = C.NOTMUCH_STATUS_XAPIAN_EXCEPTION + STATUS_FILE_ERROR Status = C.NOTMUCH_STATUS_FILE_ERROR + STATUS_FILE_NOT_EMAIL Status = C.NOTMUCH_STATUS_FILE_NOT_EMAIL + STATUS_DUPLICATE_MESSAGE_ID Status = C.NOTMUCH_STATUS_DUPLICATE_MESSAGE_ID + STATUS_NULL_POINTER Status = C.NOTMUCH_STATUS_NULL_POINTER + STATUS_TAG_TOO_LONG Status = C.NOTMUCH_STATUS_TAG_TOO_LONG + STATUS_UNBALANCED_FREEZE_THAW Status = C.NOTMUCH_STATUS_UNBALANCED_FREEZE_THAW + STATUS_UNBALANCED_ATOMIC Status = C.NOTMUCH_STATUS_UNBALANCED_ATOMIC + STATUS_UNSUPPORTED_OPERATION Status = C.NOTMUCH_STATUS_UNSUPPORTED_OPERATION + STATUS_UPGRADE_REQUIRED Status = C.NOTMUCH_STATUS_UPGRADE_REQUIRED + STATUS_PATH_ERROR Status = C.NOTMUCH_STATUS_PATH_ERROR + STATUS_IGNORED Status = C.NOTMUCH_STATUS_IGNORED + STATUS_ILLEGAL_ARGUMENT Status = C.NOTMUCH_STATUS_ILLEGAL_ARGUMENT + STATUS_MALFORMED_CRYPTO_PROTOCOL Status = C.NOTMUCH_STATUS_MALFORMED_CRYPTO_PROTOCOL + STATUS_FAILED_CRYPTO_CONTEXT_CREATION Status = C.NOTMUCH_STATUS_FAILED_CRYPTO_CONTEXT_CREATION + STATUS_UNKNOWN_CRYPTO_PROTOCOL Status = C.NOTMUCH_STATUS_UNKNOWN_CRYPTO_PROTOCOL + STATUS_NO_CONFIG Status = C.NOTMUCH_STATUS_NO_CONFIG + STATUS_NO_DATABASE Status = C.NOTMUCH_STATUS_NO_DATABASE + STATUS_DATABASE_EXISTS Status = C.NOTMUCH_STATUS_DATABASE_EXISTS + STATUS_BAD_QUERY_SYNTAX Status = C.NOTMUCH_STATUS_BAD_QUERY_SYNTAX + STATUS_NO_MAIL_ROOT Status = C.NOTMUCH_STATUS_NO_MAIL_ROOT + STATUS_CLOSED_DATABASE Status = C.NOTMUCH_STATUS_CLOSED_DATABASE +) + +func (s Status) Error() string { + status := C.notmuch_status_to_string(C.notmuch_status_t(s)) + return C.GoString(status) +} + +func errorWrap(st C.notmuch_status_t) error { + if Status(st) == STATUS_SUCCESS { + return nil + } + return Status(st) +} diff --git a/lib/notmuch/message.go b/lib/notmuch/message.go new file mode 100644 index 0000000..5b97e39 --- /dev/null +++ b/lib/notmuch/message.go @@ -0,0 +1,260 @@ +//go:build notmuch +// +build notmuch + +package notmuch + +/* +#cgo LDFLAGS: -lnotmuch + +#include <stdlib.h> +#include <notmuch.h> + +*/ +import "C" + +import ( + "time" + "unsafe" +) + +type Message struct { + message *C.notmuch_message_t +} + +// Close frees resources associated with the message +func (m *Message) Close() { + C.notmuch_message_destroy(m.message) +} + +// ID returns the message ID +func (m *Message) ID() string { + cID := C.notmuch_message_get_message_id(m.message) + return C.GoString(cID) +} + +// ThreadID returns the thread ID of the message +func (m *Message) ThreadID() string { + cID := C.notmuch_message_get_thread_id(m.message) + return C.GoString(cID) +} + +func (m *Message) Replies() Messages { + cMessages := C.notmuch_message_get_replies(m.message) + return Messages{ + messages: cMessages, + } +} + +func (m *Message) TotalFiles() int { + return int(C.notmuch_message_count_files(m.message)) +} + +// Filename returns a single filename associated with the message. If the +// message has multiple filenames, the return value will be arbitrarily chosen +func (m *Message) Filename() string { + cFilename := C.notmuch_message_get_filename(m.message) + return C.GoString(cFilename) +} + +func (m *Message) Filenames() []string { + cFilenames := C.notmuch_message_get_filenames(m.message) + defer C.notmuch_filenames_destroy(cFilenames) + + filenames := []string{} + for C.notmuch_filenames_valid(cFilenames) > 0 { + filename := C.notmuch_filenames_get(cFilenames) + filenames = append(filenames, C.GoString(filename)) + C.notmuch_filenames_move_to_next(cFilenames) + } + return filenames +} + +// TODO is this needed? +// func (m *Message) Reindex() error { +// +// } + +type Flag int + +const ( + MESSAGE_FLAG_MATCH Flag = iota + MESSAGE_FLAG_EXCLUDED + MESSAGE_FLAG_GHOST +) + +func (m *Message) Flag(flag Flag) (bool, error) { + var ok C.notmuch_bool_t + cFlag := C.notmuch_message_flag_t(flag) + err := errorWrap(C.notmuch_message_get_flag_st(m.message, cFlag, &ok)) + if err != nil { + return false, err + } + if ok == 0 { + return false, nil + } + return true, nil +} + +// TODO why does this exist?? +// func (m *Message) SetFlag(flag Flag) { +// +// } + +func (m *Message) Date() time.Time { + cTime := C.notmuch_message_get_date(m.message) + return time.Unix(int64(cTime), 0) +} + +func (m *Message) Header(field string) string { + cField := C.CString(field) + defer C.free(unsafe.Pointer(cField)) + cHeader := C.notmuch_message_get_header(m.message, cField) + return C.GoString(cHeader) +} + +func (m *Message) Tags() []string { + cTags := C.notmuch_message_get_tags(m.message) + defer C.notmuch_tags_destroy(cTags) + + tags := []string{} + for C.notmuch_tags_valid(cTags) > 0 { + tag := C.notmuch_tags_get(cTags) + tags = append(tags, C.GoString(tag)) + C.notmuch_tags_move_to_next(cTags) + } + return tags +} + +func (m *Message) AddTag(tag string) error { + cTag := C.CString(tag) + defer C.free(unsafe.Pointer(cTag)) + + return errorWrap(C.notmuch_message_add_tag(m.message, cTag)) +} + +func (m *Message) RemoveTag(tag string) error { + cTag := C.CString(tag) + defer C.free(unsafe.Pointer(cTag)) + + return errorWrap(C.notmuch_message_remove_tag(m.message, cTag)) +} + +func (m *Message) RemoveAllTags() error { + return errorWrap(C.notmuch_message_remove_all_tags(m.message)) +} + +// SyncTagsToMaildirFlags adds/removes the appropriate tags to the maildir +// filename +func (m *Message) SyncTagsToMaildirFlags() error { + return errorWrap(C.notmuch_message_tags_to_maildir_flags(m.message)) +} + +// SyncMaildirFlagsToTags syncs the current maildir flags to the notmuch tags +func (m *Message) SyncMaildirFlagsToTags() error { + return errorWrap(C.notmuch_message_maildir_flags_to_tags(m.message)) +} + +func (m *Message) HasMaildirFlag(flag rune) (bool, error) { + var ok C.notmuch_bool_t + err := errorWrap(C.notmuch_message_has_maildir_flag_st(m.message, C.char(flag), &ok)) + if err != nil { + return false, err + } + if ok == 0 { + return false, nil + } + return true, nil +} + +func (m *Message) Freeze() error { + return errorWrap(C.notmuch_message_freeze(m.message)) +} + +func (m *Message) Thaw() error { + return errorWrap(C.notmuch_message_thaw(m.message)) +} + +func (m *Message) Property(key string) (string, error) { + var ( + cKey *C.char + cValue *C.char + ) + defer C.free(unsafe.Pointer(cKey)) + defer C.free(unsafe.Pointer(cValue)) + cKey = C.CString(key) + err := errorWrap(C.notmuch_message_get_property(m.message, cKey, &cValue)) //nolint:gocritic // see note in notmuch.go + if err != nil { + return "", err + } + return C.GoString(cValue), nil +} + +func (m *Message) AddProperty(key string, value string) error { + var ( + cKey *C.char + cValue *C.char + ) + defer C.free(unsafe.Pointer(cKey)) + defer C.free(unsafe.Pointer(cValue)) + cKey = C.CString(key) + cValue = C.CString(value) + return errorWrap(C.notmuch_message_add_property(m.message, cKey, cValue)) +} + +func (m *Message) RemoveProperty(key string, value string) error { + var ( + cKey *C.char + cValue *C.char + ) + defer C.free(unsafe.Pointer(cKey)) + defer C.free(unsafe.Pointer(cValue)) + cKey = C.CString(key) + cValue = C.CString(value) + return errorWrap(C.notmuch_message_remove_property(m.message, cKey, cValue)) +} + +func (m *Message) RemoveAllProperties(key string) error { + var cKey *C.char + defer C.free(unsafe.Pointer(cKey)) + cKey = C.CString(key) + return errorWrap(C.notmuch_message_remove_all_properties(m.message, cKey)) +} + +func (m *Message) RemoveAllPropertiesWithPrefix(prefix string) error { + var cPrefix *C.char + defer C.free(unsafe.Pointer(cPrefix)) + cPrefix = C.CString(prefix) + return errorWrap(C.notmuch_message_remove_all_properties_with_prefix(m.message, cPrefix)) +} + +func (m *Message) Properties(key string, exact bool) *Properties { + var ( + cKey *C.char + cExact C.int + ) + defer C.free(unsafe.Pointer(cKey)) + if exact { + cExact = 1 + } + + cKey = C.CString(key) + props := C.notmuch_message_get_properties(m.message, cKey, cExact) + + return &Properties{ + properties: props, + } +} + +func (m *Message) CountProperties(key string) (int, error) { + var ( + cKey *C.char + cCount C.uint + ) + defer C.free(unsafe.Pointer(cKey)) + cKey = C.CString(key) + err := errorWrap(C.notmuch_message_count_properties(m.message, cKey, &cCount)) + if err != nil { + return 0, err + } + return int(cCount), nil +} diff --git a/lib/notmuch/messages.go b/lib/notmuch/messages.go new file mode 100644 index 0000000..22cc009 --- /dev/null +++ b/lib/notmuch/messages.go @@ -0,0 +1,58 @@ +//go:build notmuch +// +build notmuch + +package notmuch + +/* +#cgo LDFLAGS: -lnotmuch + +#include <notmuch.h> + +*/ +import "C" + +type Messages struct { + message *C.notmuch_message_t + messages *C.notmuch_messages_t +} + +// Next advances the Messages iterator to the next message. Next returns false if +// no more messages are available +func (m *Messages) Next() bool { + if C.notmuch_messages_valid(m.messages) == 0 { + return false + } + m.message = C.notmuch_messages_get(m.messages) + C.notmuch_messages_move_to_next(m.messages) + return true +} + +// Message returns the current message in the iterator +func (m *Messages) Message() Message { + return Message{ + message: m.message, + } +} + +// Close frees memory associated with a Messages iterator. This method is not +// strictly necessary to call, as the resources will be freed when the Query +// associated with the Messages object is freed. +func (m *Messages) Close() { + C.notmuch_messages_destroy(m.messages) +} + +// Tags returns a slice of all tags in the message list. WARNING: After calling +// tags, the message list can no longer be iterated; a new list must be created +// to iterate after calling Tags +func (m *Messages) Tags() []string { + cTags := C.notmuch_messages_collect_tags(m.messages) + defer C.notmuch_tags_destroy(cTags) + + tags := []string{} + for C.notmuch_tags_valid(cTags) > 0 { + tag := C.notmuch_tags_get(cTags) + tags = append(tags, C.GoString(tag)) + C.notmuch_tags_move_to_next(cTags) + } + return tags +} diff --git a/lib/notmuch/notmuch.go b/lib/notmuch/notmuch.go new file mode 100644 index 0000000..27c0bdf --- /dev/null +++ b/lib/notmuch/notmuch.go @@ -0,0 +1,32 @@ +//go:build notmuch +// +build notmuch + +package notmuch + +/* +#cgo LDFLAGS: -lnotmuch + +#include <stdlib.h> +#include <notmuch.h> + +#if !LIBNOTMUCH_CHECK_VERSION(5, 6, 0) +#error "aerc requires libnotmuch.so.5.6 or later" +#endif + +*/ +import "C" +import "fmt" + +const ( + MAJOR_VERSION = C.LIBNOTMUCH_MAJOR_VERSION + MINOR_VERSION = C.LIBNOTMUCH_MINOR_VERSION + MICRO_VERSION = C.LIBNOTMUCH_MICRO_VERSION +) + +func Version() string { + return fmt.Sprintf("%d.%d.%d", MAJOR_VERSION, MINOR_VERSION, MICRO_VERSION) +} + +// NOTE: Any CGO call which passes a reference to a pointer (**object) will fail +// gocritic:dupSubExpr. All of these calls are set to be ignored by the linter +// Reference: https://github.com/go-critic/go-critic/issues/897 diff --git a/lib/notmuch/properties.go b/lib/notmuch/properties.go new file mode 100644 index 0000000..6c025d0 --- /dev/null +++ b/lib/notmuch/properties.go @@ -0,0 +1,39 @@ +//go:build notmuch +// +build notmuch + +package notmuch + +/* +#cgo LDFLAGS: -lnotmuch + +#include <notmuch.h> + +*/ +import "C" + +type Properties struct { + key *C.char + value *C.char + properties *C.notmuch_message_properties_t +} + +// Next advances the Properties iterator to the next property. Next returns false if +// no more properties are available +func (p *Properties) Next() bool { + if C.notmuch_message_properties_valid(p.properties) == 0 { + return false + } + p.key = C.notmuch_message_properties_key(p.properties) + p.value = C.notmuch_message_properties_value(p.properties) + C.notmuch_message_properties_move_to_next(p.properties) + return true +} + +// Returns the key of the current iterator location +func (p *Properties) Key() string { + return C.GoString(p.key) +} + +func (p *Properties) Value() string { + return C.GoString(p.value) +} diff --git a/lib/notmuch/query.go b/lib/notmuch/query.go new file mode 100644 index 0000000..e621fcf --- /dev/null +++ b/lib/notmuch/query.go @@ -0,0 +1,120 @@ +//go:build notmuch +// +build notmuch + +package notmuch + +/* +#cgo LDFLAGS: -lnotmuch + +#include <stdlib.h> +#include <notmuch.h> + +*/ +import "C" +import "unsafe" + +type ExcludeMode int + +const ( + EXCLUDE_FLAG ExcludeMode = C.NOTMUCH_EXCLUDE_FLAG + EXCLUDE_TRUE ExcludeMode = C.NOTMUCH_EXCLUDE_TRUE + EXCLUDE_FALSE ExcludeMode = C.NOTMUCH_EXCLUDE_FALSE + EXCLUDE_ALL ExcludeMode = C.NOTMUCH_EXCLUDE_ALL +) + +type SortMode int + +const ( + SORT_OLDEST_FIRST SortMode = C.NOTMUCH_SORT_OLDEST_FIRST + SORT_NEWEST_FIRST SortMode = C.NOTMUCH_SORT_NEWEST_FIRST + SORT_MESSAGE_ID SortMode = C.NOTMUCH_SORT_MESSAGE_ID + SORT_UNSORTED SortMode = C.NOTMUCH_SORT_UNSORTED +) + +type Query struct { + query *C.notmuch_query_t +} + +// Close frees resources associated with a query. Closing a query release all +// resources associated with any underlying search (Threads, Messages, etc) +func (q *Query) Close() { + C.notmuch_query_destroy(q.query) +} + +// Return the string of the query +func (q *Query) String() string { + return C.GoString(C.notmuch_query_get_query_string(q.query)) +} + +// Returns the Database associated with the query. The Path, Config, and Profile +// values will not be set on the returned valued +func (q *Query) Database() Database { + db := C.notmuch_query_get_database(q.query) + return Database{ + db: db, + } +} + +// Exclude sets the exclusion mode. +func (q *Query) Exclude(val ExcludeMode) { + cVal := C.notmuch_exclude_t(val) + C.notmuch_query_set_omit_excluded(q.query, cVal) +} + +// Sort sets the sort order of the results +func (q *Query) Sort(sort SortMode) { + cVal := C.notmuch_sort_t(sort) + C.notmuch_query_set_sort(q.query, cVal) +} + +// SortMode returns the current sort order of the results +func (q *Query) SortMode() SortMode { + return SortMode(C.notmuch_query_get_sort(q.query)) +} + +// ExcludeTag adds a tag to exclude from the results +func (q *Query) ExcludeTag(tag string) error { + cTag := C.CString(tag) + defer C.free(unsafe.Pointer(cTag)) + return errorWrap(C.notmuch_query_add_tag_exclude(q.query, cTag)) +} + +// Threads returns an iterator over the threads that match the query +func (q *Query) Threads() (Threads, error) { + var cThreads *C.notmuch_threads_t + err := errorWrap(C.notmuch_query_search_threads(q.query, &cThreads)) //nolint:gocritic // see note in notmuch.go + if err != nil { + return Threads{}, err + } + threads := Threads{ + threads: cThreads, + } + return threads, nil +} + +// Messages returns an iterator over the messages that match the query +func (q *Query) Messages() (Messages, error) { + var cMessages *C.notmuch_messages_t + err := errorWrap(C.notmuch_query_search_messages(q.query, &cMessages)) //nolint:gocritic // see note in notmuch.go + if err != nil { + return Messages{}, err + } + messages := Messages{ + messages: cMessages, + } + return messages, nil +} + +// CountMessages returns the number of messages matching the query +func (q *Query) CountMessages() (int, error) { + var count C.uint + err := errorWrap(C.notmuch_query_count_messages(q.query, &count)) + return int(count), err +} + +// CountThreads returns the number of threads matching the query +func (q *Query) CountThreads() (int, error) { + var count C.uint + err := errorWrap(C.notmuch_query_count_threads(q.query, &count)) + return int(count), err +} diff --git a/lib/notmuch/thread.go b/lib/notmuch/thread.go new file mode 100644 index 0000000..1b6eace --- /dev/null +++ b/lib/notmuch/thread.go @@ -0,0 +1,99 @@ +//go:build notmuch +// +build notmuch + +package notmuch + +/* +#cgo LDFLAGS: -lnotmuch + +#include <stdlib.h> +#include <notmuch.h> + +*/ +import "C" +import "time" + +type Thread struct { + thread *C.notmuch_thread_t +} + +// ID returns the thread ID +func (t *Thread) ID() string { + cID := C.notmuch_thread_get_thread_id(t.thread) + return C.GoString(cID) +} + +// TotalMessages returns the total number of messages in the thread +func (t *Thread) TotalMessages() int { + return int(C.notmuch_thread_get_total_messages(t.thread)) +} + +// TotalMessages returns the total number of files in the thread +func (t *Thread) TotalFiles() int { + return int(C.notmuch_thread_get_total_files(t.thread)) +} + +// TopLevelMessages returns an iterator over the top level messages in the +// thread. Messages are sorted oldest-first +func (t *Thread) TopLevelMessages() Messages { + cMessages := C.notmuch_thread_get_toplevel_messages(t.thread) + return Messages{ + messages: cMessages, + } +} + +// Messages returns an iterator over the messages in the thread. Messages are +// sorted oldest-first +func (t *Thread) Messages() Messages { + cMessages := C.notmuch_thread_get_messages(t.thread) + return Messages{ + messages: cMessages, + } +} + +// Matches returns the number of messages in the thread that matched the query +func (t *Thread) Matches() int { + return int(C.notmuch_thread_get_matched_messages(t.thread)) +} + +// Returns a string of authors of the thread +func (t *Thread) Authors() string { + cAuthors := C.notmuch_thread_get_authors(t.thread) + return C.GoString(cAuthors) +} + +// Returns the subject of the thread +func (t *Thread) Subject() string { + cSubject := C.notmuch_thread_get_subject(t.thread) + return C.GoString(cSubject) +} + +// Returns the sent-date of the oldest message in the thread +func (t *Thread) OldestDate() time.Time { + cTime := C.notmuch_thread_get_oldest_date(t.thread) + return time.Unix(int64(cTime), 0) +} + +// Returns the sent-date of the newest message in the thread +func (t *Thread) NewestDate() time.Time { + cTime := C.notmuch_thread_get_newest_date(t.thread) + return time.Unix(int64(cTime), 0) +} + +// Tags returns a slice of all tags in the thread +func (t *Thread) Tags() []string { + cTags := C.notmuch_thread_get_tags(t.thread) + defer C.notmuch_tags_destroy(cTags) + + tags := []string{} + for C.notmuch_tags_valid(cTags) > 0 { + tag := C.notmuch_tags_get(cTags) + tags = append(tags, C.GoString(tag)) + C.notmuch_tags_move_to_next(cTags) + } + return tags +} + +func (t *Thread) Close() { + C.notmuch_thread_destroy(t.thread) +} diff --git a/lib/notmuch/threads.go b/lib/notmuch/threads.go new file mode 100644 index 0000000..6a2c7b6 --- /dev/null +++ b/lib/notmuch/threads.go @@ -0,0 +1,44 @@ +//go:build notmuch +// +build notmuch + +package notmuch + +/* +#cgo LDFLAGS: -lnotmuch + +#include <stdlib.h> +#include <notmuch.h> + +*/ +import "C" + +// Threads is an iterator over a set of threads. +type Threads struct { + thread *C.notmuch_thread_t + threads *C.notmuch_threads_t +} + +// Next advances the Threads iterator to the next thread. Next returns false if +// no more threads are available +func (t *Threads) Next() bool { + if C.notmuch_threads_valid(t.threads) == 0 { + return false + } + t.thread = C.notmuch_threads_get(t.threads) + C.notmuch_threads_move_to_next(t.threads) + return true +} + +// Thread returns the current thread in the iterator +func (t *Threads) Thread() Thread { + return Thread{ + thread: t.thread, + } +} + +// Close frees memory associated with a Threads iterator. This method is not +// strictly necessary to call, as the resources will be freed when the Query +// associated with the Threads object is freed. +func (t *Threads) Close() { + C.notmuch_threads_destroy(t.threads) +} diff --git a/lib/notmuch_version.go b/lib/notmuch_version.go new file mode 100644 index 0000000..c2025bd --- /dev/null +++ b/lib/notmuch_version.go @@ -0,0 +1,10 @@ +//go:build notmuch +// +build notmuch + +package lib + +import "git.sr.ht/~rjarry/aerc/lib/notmuch" + +func NotmuchVersion() (string, bool) { + return notmuch.Version(), true +} diff --git a/lib/notmuch_version_dummy.go b/lib/notmuch_version_dummy.go new file mode 100644 index 0000000..f04905e --- /dev/null +++ b/lib/notmuch_version_dummy.go @@ -0,0 +1,8 @@ +//go:build !notmuch +// +build !notmuch + +package lib + +func NotmuchVersion() (string, bool) { + return "", false +} diff --git a/lib/oauthbearer.go b/lib/oauthbearer.go new file mode 100644 index 0000000..063866b --- /dev/null +++ b/lib/oauthbearer.go @@ -0,0 +1,43 @@ +package lib + +import ( + "context" + "fmt" + + "github.com/emersion/go-imap/client" + "github.com/emersion/go-sasl" + "golang.org/x/oauth2" +) + +type OAuthBearer struct { + OAuth2 *oauth2.Config + Enabled bool +} + +func (c *OAuthBearer) ExchangeRefreshToken(refreshToken string) (*oauth2.Token, error) { + token := new(oauth2.Token) + token.RefreshToken = refreshToken + token.TokenType = "Bearer" + return c.OAuth2.TokenSource(context.TODO(), token).Token() +} + +func (c *OAuthBearer) Authenticate(username string, password string, client *client.Client) error { + if ok, err := client.SupportAuth(sasl.OAuthBearer); err != nil || !ok { + return fmt.Errorf("OAuthBearer not supported %w", err) + } + + if c.OAuth2.Endpoint.TokenURL != "" { + token, err := c.ExchangeRefreshToken(password) + if err != nil { + return err + } + password = token.AccessToken + } + + saslClient := sasl.NewOAuthBearerClient(&sasl.OAuthBearerOptions{ + Username: username, + Token: password, + }) + + return client.Authenticate(saslClient) +} diff --git a/lib/open.go b/lib/open.go new file mode 100644 index 0000000..edbc7ff --- /dev/null +++ b/lib/open.go @@ -0,0 +1,55 @@ +package lib + +import ( + "fmt" + "os/exec" + "runtime" + "strings" + + "git.sr.ht/~rjarry/go-opt/v2" + "github.com/danwakefield/fnmatch" + + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/lib/log" +) + +func XDGOpenMime( + uri string, mimeType string, args string, +) error { + if len(args) == 0 { + // no explicit command provided, lookup opener from mime type + for _, o := range config.Openers { + if fnmatch.Match(o.Mime, mimeType, 0) { + args = o.Args + break + } + } + } + if len(args) == 0 { + // no opener defined in config, fallback to default + if runtime.GOOS == "darwin" { + args = "open" + } else { + args = "xdg-open" + } + } + + // Escape URI special characters + uri = opt.QuoteArg(uri) + if strings.Contains(args, "{}") { + // found {} placeholder in args, replace with uri + args = strings.Replace(args, "{}", uri, 1) + } else { + // no {} placeholder in args, add uri at the end + args = args + " " + uri + } + + log.Tracef("running command: %v", args) + cmd := exec.Command("sh", "-c", args) + out, err := cmd.CombinedOutput() + log.Debugf("command: %v exited. err=%v out=%s", args, err, out) + if err != nil { + return fmt.Errorf("%v: %w", args, err) + } + return nil +} diff --git a/lib/pama/apply.go b/lib/pama/apply.go new file mode 100644 index 0000000..2dd9ecf --- /dev/null +++ b/lib/pama/apply.go @@ -0,0 +1,138 @@ +package pama + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + mathrand "math/rand" + "strings" + + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/pama/models" +) + +func (m PatchManager) CurrentProject() (p models.Project, err error) { + store := m.store() + name, err := store.CurrentName() + if name == "" || err != nil { + log.Errorf("failed to get current name: %v", storeErr(err)) + err = fmt.Errorf("no current project set. " + + "Run :patch init first") + return + } + names, err := store.Names() + if err != nil { + err = storeErr(err) + return + } + notFound := true + for _, s := range names { + if s == name { + notFound = !notFound + break + } + } + if notFound { + err = fmt.Errorf("project '%s' does not exist anymore. "+ + "Run :patch init or :patch switch", name) + return + } + p, err = store.Current() + if err != nil { + err = storeErr(err) + } + return +} + +func (m PatchManager) CurrentPatches() ([]string, error) { + c, err := m.CurrentProject() + if err != nil { + return nil, err + } + return models.Commits(c.Commits).Tags(), nil +} + +func (m PatchManager) Head(p models.Project) (string, error) { + rc, err := m.rc(p.RevctrlID, p.Root) + if err != nil { + return "", revErr(err) + } + return rc.Head() +} + +func (m PatchManager) Clean(p models.Project) bool { + rc, err := m.rc(p.RevctrlID, p.Root) + if err != nil { + log.Errorf("could not get revctl: %v", revErr(err)) + return false + } + return rc.Clean() +} + +func (m PatchManager) ApplyCmd(p models.Project) (string, error) { + rc, err := m.rc(p.RevctrlID, p.Root) + if err != nil { + return "", revErr(err) + } + return rc.ApplyCmd(), nil +} + +func generateTag(n int) (string, error) { + b := make([]byte, n) + _, err := rand.Read(b) + if err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +func makeUnique(s string) string { + tag, err := generateTag(4) + if err != nil { + return fmt.Sprintf("%s_%d", s, mathrand.Uint32()) + } + return fmt.Sprintf("%s_%s", s, tag) +} + +// ApplyUpdate is called after the commits have been applied with the +// ApplyCmd(). It will determine the additional commits from the commitID (last +// HEAD position), assign the patch tag to those commits and store them in +// project p. +func (m PatchManager) ApplyUpdate(p models.Project, patch, commitID string, + kv map[string]string, +) (models.Project, error) { + rc, err := m.rc(p.RevctrlID, p.Root) + if err != nil { + return p, revErr(err) + } + + commitIDs, err := rc.History(commitID) + if err != nil { + return p, revErr(err) + } + if len(commitIDs) == 0 { + return p, fmt.Errorf("no commits found for patch %s", patch) + } + + if models.Commits(p.Commits).HasTag(patch) { + log.Warnf("Patch name '%s' already exists", patch) + patch = makeUnique(patch) + log.Warnf("Creating new name: '%s'", patch) + } + + for _, c := range commitIDs { + nc := models.NewCommit(rc, c, patch) + for msgid, subj := range kv { + if nc.Subject == "" { + continue + } + if strings.Contains(subj, nc.Subject) { + nc.MessageId = msgid + } + } + p.Commits = append(p.Commits, nc) + } + + err = m.store().StoreProject(p, true) + return p, storeErr(err) +} diff --git a/lib/pama/drop.go b/lib/pama/drop.go new file mode 100644 index 0000000..fd48575 --- /dev/null +++ b/lib/pama/drop.go @@ -0,0 +1,94 @@ +package pama + +import ( + "fmt" + + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/pama/models" +) + +func (m PatchManager) DropPatch(patch string) error { + p, err := m.CurrentProject() + if err != nil { + return err + } + + if !models.Commits(p.Commits).HasTag(patch) { + return fmt.Errorf("Patch '%s' not found in project '%s'", patch, p.Name) + } + + rc, err := m.rc(p.RevctrlID, p.Root) + if err != nil { + return revErr(err) + } + + if !rc.Clean() { + return fmt.Errorf("Aborting... There are unstaged changes " + + "or a rebase in progress") + } + + toRemove := make([]models.Commit, 0) + for _, c := range p.Commits { + if !rc.Exists(c.ID) { + log.Errorf("failed to find commit. %v", c) + return fmt.Errorf("Cannot drop patch. " + + "Please rebase first with ':patch rebase'") + } + if c.Tag == patch { + toRemove = append(toRemove, c) + } + } + + removed := make(map[string]struct{}) + for i := len(toRemove) - 1; i >= 0; i-- { + commitID := toRemove[i].ID + beforeIDs, err := rc.History(commitID) + if err != nil { + log.Errorf("failed to drop %v (commits before): %v", toRemove[i], err) + continue + } + err = rc.Drop(commitID) + if err != nil { + log.Errorf("failed to drop %v: %v", toRemove[i], err) + continue + } + removed[commitID] = struct{}{} + afterIDs, err := rc.History(p.Base.ID) + if err != nil { + log.Errorf("failed to drop %v (commits after): %v", toRemove[i], err) + continue + } + afterIDs = afterIDs[len(afterIDs)-len(beforeIDs):] + transform := make(map[string]string) + for j := 0; j < len(beforeIDs); j++ { + transform[beforeIDs[j]] = afterIDs[j] + } + for j, c := range p.Commits { + if newId, ok := transform[c.ID]; ok { + msgid := p.Commits[j].MessageId + p.Commits[j] = models.NewCommit( + rc, + newId, + p.Commits[j].Tag, + ) + p.Commits[j].MessageId = msgid + } + } + } + + if len(removed) < len(toRemove) { + return fmt.Errorf("Failed to drop commits. Dropped %d of %d.", + len(removed), len(toRemove)) + } + + commits := make([]models.Commit, 0, len(p.Commits)) + for _, c := range p.Commits { + if _, ok := removed[c.ID]; ok { + continue + } + commits = append(commits, c) + } + p.Commits = commits + + return storeErr(m.store().StoreProject(p, true)) +} diff --git a/lib/pama/drop_test.go b/lib/pama/drop_test.go new file mode 100644 index 0000000..84bc286 --- /dev/null +++ b/lib/pama/drop_test.go @@ -0,0 +1,85 @@ +package pama_test + +import ( + "reflect" + "testing" + + "git.sr.ht/~rjarry/aerc/lib/pama" + "git.sr.ht/~rjarry/aerc/lib/pama/models" +) + +func TestPatchmgmt_Drop(t *testing.T) { + setup := func(p models.Project) (pama.PatchManager, models.RevisionController, models.PersistentStorer) { + return newTestManager( + []string{"0", "1", "2", "3", "4", "5"}, + []string{"0", "a", "b", "c", "d", "f"}, + map[string]models.Project{p.Name: p}, p.Name, + ) + } + + tests := []struct { + name string + drop string + commits []models.Commit + want []models.Commit + }{ + { + name: "drop only patch", + drop: "patch1", + commits: []models.Commit{ + newCommit("1", "a", "patch1"), + }, + want: []models.Commit{}, + }, + { + name: "drop second one of two patch", + drop: "patch2", + commits: []models.Commit{ + newCommit("1", "a", "patch1"), + newCommit("2", "b", "patch2"), + }, + want: []models.Commit{ + newCommit("1", "a", "patch1"), + }, + }, + { + name: "drop first one of two patch", + drop: "patch1", + commits: []models.Commit{ + newCommit("1", "a", "patch1"), + newCommit("2", "b", "patch2"), + }, + want: []models.Commit{ + newCommit("2_new", "b", "patch2"), + }, + }, + } + + for _, test := range tests { + p := models.Project{ + Name: "project1", + Commits: test.commits, + Base: newCommit("0", "0", ""), + } + mgr, rc, _ := setup(p) + + err := mgr.DropPatch(test.drop) + if err != nil { + t.Errorf("test '%s' failed. %v", test.name, err) + } + + q, _ := mgr.CurrentProject() + if !reflect.DeepEqual(q.Commits, test.want) { + t.Errorf("test '%s' failed. Commits don't match: "+ + "got %v, but wanted %v", test.name, q.Commits, + test.want) + } + + if len(test.want) > 0 { + last := test.want[len(test.want)-1] + if !rc.Exists(last.ID) { + t.Errorf("test '%s' failed. Could not find last commits: %v", test.name, last) + } + } + } +} diff --git a/lib/pama/find.go b/lib/pama/find.go new file mode 100644 index 0000000..72203ee --- /dev/null +++ b/lib/pama/find.go @@ -0,0 +1,19 @@ +package pama + +import ( + "fmt" + + "git.sr.ht/~rjarry/aerc/lib/pama/models" +) + +func (m PatchManager) Find(hash string, p models.Project) (models.Commit, error) { + var c models.Commit + rc, err := m.rc(p.RevctrlID, p.Root) + if err != nil { + return c, revErr(err) + } + if !rc.Exists(hash) { + return c, fmt.Errorf("no commit found for hash %s", hash) + } + return models.NewCommit(rc, hash, ""), nil +} diff --git a/lib/pama/init.go b/lib/pama/init.go new file mode 100644 index 0000000..da33414 --- /dev/null +++ b/lib/pama/init.go @@ -0,0 +1,34 @@ +package pama + +import ( + "git.sr.ht/~rjarry/aerc/lib/pama/models" +) + +// Init creates a new revision control project +func (m PatchManager) Init(name, path string, overwrite bool) error { + id, root, err := m.detect(path) + if err != nil { + return err + } + rc, err := m.rc(id, root) + if err != nil { + return err + } + headID, err := rc.Head() + if err != nil { + return err + } + p := models.Project{ + Name: name, + Root: root, + RevctrlID: id, + Base: models.NewCommit(rc, headID, ""), + Commits: make([]models.Commit, 0), + } + store := m.store() + err = store.StoreProject(p, overwrite) + if err != nil { + return storeErr(err) + } + return storeErr(store.SetCurrent(name)) +} diff --git a/lib/pama/list.go b/lib/pama/list.go new file mode 100644 index 0000000..7ab9e3d --- /dev/null +++ b/lib/pama/list.go @@ -0,0 +1,59 @@ +package pama + +import ( + "errors" + "io" + "strings" + + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/pama/models" +) + +func (m PatchManager) Projects(name string) ([]models.Project, error) { + all, err := m.store().Projects() + if err != nil { + return nil, storeErr(err) + } + if len(name) == 0 { + return all, nil + } + var projects []models.Project + for _, p := range all { + if strings.Contains(p.Name, name) { + projects = append(projects, p) + } + } + if len(projects) == 0 { + return nil, errors.New("No projects found.") + } + return projects, nil +} + +func (m PatchManager) NewReader(projects []models.Project) io.Reader { + cur, err := m.CurrentProject() + currentName := cur.Name + if err != nil { + log.Warnf("could not get current project: %v", err) + currentName = "" + } + + readers := make([]io.Reader, 0, len(projects)) + for _, p := range projects { + rc, err := m.rc(p.RevctrlID, p.Root) + if err != nil { + log.Errorf("project '%s' failed with: %v", p.Name, err) + continue + } + + notes := make(map[string]string) + for _, c := range p.Commits { + if !rc.Exists(c.ID) { + notes[c.ID] = "Rebase needed" + } + } + + active := p.Name == currentName && len(projects) > 1 + readers = append(readers, p.NewReader(active, notes)) + } + return io.MultiReader(readers...) +} diff --git a/lib/pama/models/commit.go b/lib/pama/models/commit.go new file mode 100644 index 0000000..4cd06b4 --- /dev/null +++ b/lib/pama/models/commit.go @@ -0,0 +1,93 @@ +package models + +import ( + "fmt" + "strings" +) + +const ( + Untracked = "untracked" +) + +func NewCommit(r RevisionController, id, tag string) Commit { + return Commit{ + ID: id, + Subject: r.Subject(id), + Author: r.Author(id), + Date: r.Date(id), + MessageId: "", + Tag: tag, + } +} + +func (c Commit) Untracked() bool { + return c.Tag == Untracked +} + +func (c Commit) Info() string { + s := []string{} + if c.Subject == "" { + s = append(s, "(no subject)") + } else { + s = append(s, c.Subject) + } + if c.Author != "" { + s = append(s, c.Author) + } + if c.Date != "" { + s = append(s, c.Date) + } + if c.MessageId != "" { + s = append(s, "<"+c.MessageId+">") + } + return strings.Join(s, ", ") +} + +func (c Commit) String() string { + return fmt.Sprintf("%-6.6s %s", c.ID, c.Info()) +} + +type Commits []Commit + +func (h Commits) Tags() []string { + var tags []string + dedup := make(map[string]struct{}) + for _, c := range h { + _, ok := dedup[c.Tag] + if ok { + continue + } + tags = append(tags, c.Tag) + dedup[c.Tag] = struct{}{} + } + return tags +} + +func (h Commits) HasTag(t string) bool { + for _, c := range h { + if c.Tag == t { + return true + } + } + return false +} + +func (h Commits) Lookup(id string) (Commit, bool) { + for _, c := range h { + if c.ID == id { + return c, true + } + } + return Commit{}, false +} + +type CommitIDs []string + +func (c CommitIDs) Has(id string) bool { + for _, cid := range c { + if cid == id { + return true + } + } + return false +} diff --git a/lib/pama/models/models.go b/lib/pama/models/models.go new file mode 100644 index 0000000..924369f --- /dev/null +++ b/lib/pama/models/models.go @@ -0,0 +1,106 @@ +package models + +// Commit represents a commit object in a revision control system. +type Commit struct { + // ID is the commit hash. + ID string + // Subject is the subject line of the commit. + Subject string + // Author is the author's name. + Author string + // Date associated with the given commit. + Date string + // MessageId is the message id for the message that contains the commit + // diff. This field is only set when commits were applied via patch + // apply system. + MessageId string + // Tag is a user label that is assigned to one or multiple commits. It + // creates a logical connection between a group of commits to represent + // a patch set. + Tag string +} + +// WorktreeParent stores the name and repo location for the base project in the +// linked worktree project. +type WorktreeParent struct { + // Name is the project name from the base repo. + Name string + // Root is the root directory of the base repo. + Root string +} + +// Project contains the data to access a revision control system and to store +// the internal patch tracking data. +type Project struct { + // Name is the project name and works as the project ID. Do not change + // it. + Name string + // Root represents the root directory of the revision control system. + Root string + // RevctrlID stores the ID for the revision control system. + RevctrlID string + // Worktree keeps the base repo information. If Worktree.Name and + // Worktree.Root are not zero, this project contains a linked worktree. + Worktree WorktreeParent + // Base represents the reference (base) commit. + Base Commit + // Commits contains the commits that are being tracked. The slice can + // contain any commit between the Base commit and HEAD. These commits + // will be updated by an applying, removing or rebase operation. + Commits []Commit +} + +// RevisionController is an interface to a revision control system. +type RevisionController interface { + // Returns the commit hash of the HEAD commit. + Head() (string, error) + // History accepts a commit hash and returns a list of commit hashes + // between the provided hash and HEAD. The order of the returned slice + // is important. The commit hashes should be ordered from "earlier" to + // "later" where the last element must be HEAD. + History(string) ([]string, error) + // Clean returns true if there are no unstaged changes. If there are + // unstaged changes, applying and removing patches will not work. + Clean() bool + // Exists returns true if the commit hash exists in the commit history. + Exists(string) bool + // Subject returns the subject line for the provided commit hash. + Subject(string) string + // Author returns the author for the provided commit hash. + Author(string) string + // Date returns the date for the provided commit hash. + Date(string) string + // Drop removes the commit with the provided commit hash from the + // repository. + Drop(string) error + // ApplyCmd returns a string with an executable command that is used to + // apply patches with the :pipe command. + ApplyCmd() string + // CreateWorktree creates a worktree in path at commit. + CreateWorktree(path string, commit string) error + // DeleteWorktree removes the linked worktree stored in the path + // location. Note that this function should be called from the base + // repo. + DeleteWorktree(path string) error +} + +// PersistentStorer is an interface to a persistent storage for Project structs. +type PersistentStorer interface { + // StoreProject saves the project data persistently. If overwrite is + // true, it will write over existing data. + StoreProject(Project, bool) error + // DeleteProject removes the project data from the store. + DeleteProject(string) error + // CurrentName returns the Project.Name for the active project. + CurrentName() (string, error) + // SetCurrent stores a Project.Name and make that project active. + SetCurrent(string) error + // Current returns the project data for the active project. + Current() (Project, error) + // Names returns a slice of Project.Name for all stored projects. + Names() ([]string, error) + // Project returns the stored project for the provided name. + Project(string) (Project, error) + // Projects returns all stored projects. + Projects() ([]Project, error) +} diff --git a/lib/pama/models/view.go b/lib/pama/models/view.go new file mode 100644 index 0000000..ff2aa71 --- /dev/null +++ b/lib/pama/models/view.go @@ -0,0 +1,85 @@ +package models + +import ( + "bytes" + "io" + "strings" + "text/template" + + "git.sr.ht/~rjarry/aerc/lib/log" +) + +var templateText = ` +Project {{.Name}} {{if .IsActive}}[active]{{end}} {{if .IsWorktree}}[Linked worktree to {{.WorktreeParent}}]{{end}} +Directory {{.Root}} +Base {{with .Base.ID}}{{if ge (len .) 40}}{{printf "%-6.6s" .}}{{else}}{{.}}{{end}}{{end}} +{{$notes := .Notes}}{{$commits := .Commits}} +{{- range $index, $patch := .Patches}} + {{$patch}}: + {{- range (index $commits $patch)}} + {{with (index $notes .ID)}}[{{.}}] {{end}}{{. -}} + {{end}} +{{end -}} +` + +var viewRenderer = template.Must(template.New("ProjectToText").Parse(templateText)) + +type view struct { + Name string + Root string + Base Commit + // Patches are the unique tag names. + Patches []string + // Commits is a map where the tag names are keys and the associated + // commits the values. + Commits map[string][]Commit + // Notes contain annotations of the commits where the commit hash is + // the key and the annotation is the value. + Notes map[string]string + // IsActive is true if the current project is selected. + IsActive bool + IsWorktree bool + WorktreeParent string +} + +func newView(p Project, active bool, notes map[string]string) view { + v := view{ + Name: p.Name, + Root: p.Root, + Base: p.Base, + Commits: make(map[string][]Commit), + Notes: notes, + IsActive: active, + IsWorktree: p.Worktree.Root != "" && p.Worktree.Name != "", + WorktreeParent: p.Worktree.Name, + } + + for _, commit := range p.Commits { + patch := commit.Tag + commits, ok := v.Commits[patch] + if !ok { + v.Patches = append(v.Patches, patch) + } + commits = append(commits, commit) + v.Commits[patch] = commits + } + + return v +} + +func (v view) String() string { + var buf bytes.Buffer + err := viewRenderer.Execute(&buf, v) + if err != nil { + log.Errorf("failed to run template: %v", err) + } + return buf.String() +} + +func (p Project) String() string { + return newView(p, false, nil).String() +} + +func (p Project) NewReader(isActive bool, notes map[string]string) io.Reader { + return strings.NewReader(newView(p, isActive, notes).String()) +} diff --git a/lib/pama/pama.go b/lib/pama/pama.go new file mode 100644 index 0000000..6fe91fe --- /dev/null +++ b/lib/pama/pama.go @@ -0,0 +1,51 @@ +package pama + +import ( + "fmt" + + "git.sr.ht/~rjarry/aerc/lib/pama/models" + "git.sr.ht/~rjarry/aerc/lib/pama/revctrl" + "git.sr.ht/~rjarry/aerc/lib/pama/store" +) + +type ( + detectFn func(string) (string, string, error) + rcFn func(string, string) (models.RevisionController, error) + storeFn func() models.PersistentStorer +) + +type PatchManager struct { + detect detectFn + rc rcFn + store storeFn +} + +func New() PatchManager { + return PatchManager{ + detect: revctrl.Detect, + rc: revctrl.New, + store: store.Store, + } +} + +func FromFunc(d detectFn, r rcFn, s storeFn) PatchManager { + return PatchManager{ + detect: d, + rc: r, + store: s, + } +} + +func storeErr(err error) error { + if err == nil { + return nil + } + return fmt.Errorf("store error: %w", err) +} + +func revErr(err error) error { + if err == nil { + return nil + } + return fmt.Errorf("revision control error: %w", err) +} diff --git a/lib/pama/pama_test.go b/lib/pama/pama_test.go new file mode 100644 index 0000000..ccb2ba2 --- /dev/null +++ b/lib/pama/pama_test.go @@ -0,0 +1,178 @@ +package pama_test + +import ( + "errors" + + "git.sr.ht/~rjarry/aerc/lib/pama" + "git.sr.ht/~rjarry/aerc/lib/pama/models" +) + +var errNotFound = errors.New("not found") + +func newCommit(id, subj, tag string) models.Commit { + return models.Commit{ID: id, Subject: subj, Tag: tag} +} + +func newTestManager( + commits []string, + subjects []string, + data map[string]models.Project, + current string, +) (pama.PatchManager, models.RevisionController, models.PersistentStorer) { + rc := mockRevctrl{ + commitIDs: commits, + titles: subjects, + } + store := mockStore{ + data: data, + current: current, + } + return pama.FromFunc( + nil, + func(_ string, _ string) (models.RevisionController, error) { + return &rc, nil + }, + func() models.PersistentStorer { + return &store + }, + ), &rc, &store +} + +type mockRevctrl struct { + commitIDs []string + titles []string +} + +func (c *mockRevctrl) Support() bool { + return true +} + +func (c *mockRevctrl) Clean() bool { + return true +} + +func (c *mockRevctrl) Root() (string, error) { + return "", nil +} + +func (c *mockRevctrl) Head() (string, error) { + return c.commitIDs[len(c.commitIDs)-1], nil +} + +func (c *mockRevctrl) History(commit string) ([]string, error) { + for i, s := range c.commitIDs { + if s == commit { + cp := make([]string, len(c.commitIDs[i+1:])) + copy(cp, c.commitIDs[i+1:]) + return cp, nil + } + } + return nil, errNotFound +} + +func (c *mockRevctrl) Exists(commit string) bool { + for _, s := range c.commitIDs { + if s == commit { + return true + } + } + return false +} + +func (c *mockRevctrl) Subject(commit string) string { + for i, s := range c.commitIDs { + if s == commit { + return c.titles[i] + } + } + return "" +} + +func (c *mockRevctrl) Author(commit string) string { + return "" +} + +func (c *mockRevctrl) Date(commit string) string { + return "" +} + +func (c *mockRevctrl) Drop(commit string) error { + for i, s := range c.commitIDs { + if s == commit { + c.commitIDs = append(c.commitIDs[:i], c.commitIDs[i+1:]...) + c.titles = append(c.titles[:i], c.titles[i+1:]...) + // modify commitIDs to simulate a "real" change in + // commit history that will also change all subsequent + // commitIDs + for j := i; j < len(c.commitIDs); j++ { + c.commitIDs[j] += "_new" + } + return nil + } + } + return errNotFound +} + +func (c *mockRevctrl) CreateWorktree(_, _ string) error { + return nil +} + +func (c *mockRevctrl) DeleteWorktree(_ string) error { + return nil +} + +func (c *mockRevctrl) ApplyCmd() string { + return "" +} + +type mockStore struct { + data map[string]models.Project + current string +} + +func (s *mockStore) StoreProject(p models.Project, ow bool) error { + _, ok := s.data[p.Name] + if ok && !ow { + return errors.New("already there") + } + s.data[p.Name] = p + return nil +} + +func (s *mockStore) DeleteProject(name string) error { + delete(s.data, name) + return nil +} + +func (s *mockStore) CurrentName() (string, error) { + return s.current, nil +} + +func (s *mockStore) SetCurrent(c string) error { + s.current = c + return nil +} + +func (s *mockStore) Current() (models.Project, error) { + return s.data[s.current], nil +} + +func (s *mockStore) Names() ([]string, error) { + var names []string + for name := range s.data { + names = append(names, name) + } + return names, nil +} + +func (s *mockStore) Project(_ string) (models.Project, error) { + return models.Project{}, nil +} + +func (s *mockStore) Projects() ([]models.Project, error) { + var ps []models.Project + for _, p := range s.data { + ps = append(ps, p) + } + return ps, nil +} diff --git a/lib/pama/rebase.go b/lib/pama/rebase.go new file mode 100644 index 0000000..8f316b7 --- /dev/null +++ b/lib/pama/rebase.go @@ -0,0 +1,81 @@ +package pama + +import ( + "fmt" + + "git.sr.ht/~rjarry/aerc/lib/pama/models" +) + +// RebaseCommits fetches the commits between baseID and HEAD. The tags from the +// current project will be mapped onto the fetched commits based on either the +// commit hash or the commit subject. +func (m PatchManager) RebaseCommits(p models.Project, baseID string) ([]models.Commit, error) { + rc, err := m.rc(p.RevctrlID, p.Root) + if err != nil { + return nil, revErr(err) + } + + if !rc.Exists(baseID) { + return nil, fmt.Errorf("cannot rebase on %s. "+ + "commit does not exist", baseID) + } + + commitIDs, err := rc.History(baseID) + if err != nil { + return nil, err + } + + commits := make([]models.Commit, len(commitIDs)) + for i := 0; i < len(commitIDs); i++ { + commits[i] = models.NewCommit( + rc, + commitIDs[i], + models.Untracked, + ) + } + + // map tags from the commits from the project p + for i, r := range commits { + for _, c := range p.Commits { + if c.ID == r.ID || c.Subject == r.Subject { + commits[i].MessageId = c.MessageId + commits[i].Tag = c.Tag + break + } + } + } + + return commits, nil +} + +// SaveRebased checks if the commits actually exist in the repo, repopulate the +// info fields and saves the baseID for project p. +func (m PatchManager) SaveRebased(p models.Project, baseID string, commits []models.Commit) error { + rc, err := m.rc(p.RevctrlID, p.Root) + if err != nil { + return revErr(err) + } + + exist := make([]models.Commit, 0, len(commits)) + for _, c := range commits { + if !rc.Exists(c.ID) { + continue + } + exist = append(exist, c) + } + + for i, c := range exist { + exist[i].Subject = rc.Subject(c.ID) + exist[i].Author = rc.Author(c.ID) + exist[i].Date = rc.Date(c.ID) + } + + p.Commits = exist + + if rc.Exists(baseID) { + p.Base = models.NewCommit(rc, baseID, "") + } + + err = m.store().StoreProject(p, true) + return storeErr(err) +} diff --git a/lib/pama/revctrl/git.go b/lib/pama/revctrl/git.go new file mode 100644 index 0000000..ce02407 --- /dev/null +++ b/lib/pama/revctrl/git.go @@ -0,0 +1,128 @@ +package revctrl + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/pama/models" +) + +func init() { + register("git", newGit) +} + +func newGit(s string) models.RevisionController { + return &git{path: strings.TrimSpace(s)} +} + +type git struct { + path string +} + +func (g git) Support() bool { + _, exitcode, err := g.do("rev-parse") + return exitcode == 0 && err == nil +} + +func (g git) Root() (string, error) { + s, _, err := g.do("rev-parse", "--show-toplevel") + return s, err +} + +func (g git) Head() (string, error) { + s, _, err := g.do("rev-list", "-n 1", "HEAD") + return s, err +} + +func (g git) History(commit string) ([]string, error) { + s, _, err := g.do("rev-list", "--reverse", fmt.Sprintf("%s..HEAD", commit)) + return strings.Fields(s), err +} + +func (g git) Subject(commit string) string { + s, exitcode, err := g.do("log", "-1", "--pretty=%s", commit) + if exitcode > 0 || err != nil { + return "" + } + return s +} + +func (g git) Author(commit string) string { + s, exitcode, err := g.do("log", "-1", "--pretty=%an", commit) + if exitcode > 0 || err != nil { + return "" + } + return s +} + +func (g git) Date(commit string) string { + s, exitcode, err := g.do("log", "-1", "--pretty=%as", commit) + if exitcode > 0 || err != nil { + return "" + } + return s +} + +func (g git) Drop(commit string) error { + _, exitcode, err := g.do("rebase", "--onto", commit+"^", commit) + if exitcode > 0 { + return fmt.Errorf("failed to drop commit %s", commit) + } + return err +} + +func (g git) Exists(commit string) bool { + _, exitcode, err := g.do("merge-base", "--is-ancestor", commit, "HEAD") + return exitcode == 0 && err == nil +} + +func (g git) Clean() bool { + // is a rebase in progress? + dirs := []string{"rebase-merge", "rebase-apply"} + for _, dir := range dirs { + relPath, _, err := g.do("rev-parse", "--git-path", dir) + if err == nil { + if _, err := os.Stat(filepath.Join(g.path, relPath)); !os.IsNotExist(err) { + log.Errorf("%s exists: another rebase in progress..", dir) + return false + } + } + } + // are there unstaged changes? + s, exitcode, err := g.do("diff-index", "HEAD") + return len(s) == 0 && exitcode == 0 && err == nil +} + +func (g git) CreateWorktree(target, commit string) error { + _, exitcode, err := g.do("worktree", "add", target, commit) + if exitcode > 0 { + return fmt.Errorf("failed to create worktree in %s: %w", target, err) + } + return err +} + +func (g git) DeleteWorktree(target string) error { + _, exitcode, err := g.do("worktree", "remove", target) + if exitcode > 0 { + return fmt.Errorf("failed to delete worktree in %s: %w", target, err) + } + return err +} + +func (g git) ApplyCmd() string { + // TODO: should we return a *exec.Cmd instead of a string? + return fmt.Sprintf("git -C %s am -3 --empty drop", g.path) +} + +func (g git) do(args ...string) (string, int, error) { + proc := exec.Command("git", "-C", g.path) + proc.Args = append(proc.Args, args...) + proc.Env = os.Environ() + result, err := proc.Output() + return string(bytes.TrimSpace(result)), proc.ProcessState.ExitCode(), err +} diff --git a/lib/pama/revctrl/revctrl.go b/lib/pama/revctrl/revctrl.go new file mode 100644 index 0000000..cd9202f --- /dev/null +++ b/lib/pama/revctrl/revctrl.go @@ -0,0 +1,48 @@ +package revctrl + +import ( + "errors" + "fmt" + + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/pama/models" +) + +var ErrUnsupported = errors.New("unsupported") + +type factoryFunc func(string) models.RevisionController + +var controllers = map[string]factoryFunc{} + +func register(controllerID string, fn factoryFunc) { + controllers[controllerID] = fn +} + +func New(controllerID string, path string) (models.RevisionController, error) { + factoryFunc, ok := controllers[controllerID] + if !ok { + return nil, errors.New("cannot create revision control instance") + } + return factoryFunc(path), nil +} + +type detector interface { + Support() bool + Root() (string, error) +} + +func Detect(path string) (string, string, error) { + for controllerID, factoryFunc := range controllers { + rc, ok := factoryFunc(path).(detector) + if ok && rc.Support() { + log.Tracef("support found for %v", controllerID) + root, err := rc.Root() + if err != nil { + continue + } + log.Tracef("root found in %s", root) + return controllerID, root, nil + } + } + return "", "", fmt.Errorf("no supported repository found in %s", path) +} diff --git a/lib/pama/store/store.go b/lib/pama/store/store.go new file mode 100644 index 0000000..043c42b --- /dev/null +++ b/lib/pama/store/store.go @@ -0,0 +1,261 @@ +package store + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path" + "strings" + + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/pama/models" + "git.sr.ht/~rjarry/aerc/lib/xdg" + "github.com/syndtr/goleveldb/leveldb" +) + +const ( + keyPrefix = "project." +) + +var ( + // versTag should be incremented when the underlying data structure + // changes. + versTag = []byte("0001") + versTagKey = []byte("version.tag") + currentKey = []byte("current.project") +) + +func createKey(name string) []byte { + return []byte(keyPrefix + name) +} + +func parseKey(key []byte) string { + return strings.TrimPrefix(string(key), keyPrefix) +} + +func isProjectKey(key []byte) bool { + return bytes.HasPrefix(key, []byte(keyPrefix)) +} + +func cacheDir() (string, error) { + dir, err := os.UserCacheDir() + if err != nil { + dir = xdg.ExpandHome("~/.cache") + } + return path.Join(dir, "aerc"), nil +} + +func openStorage() (*leveldb.DB, error) { + cd, err := cacheDir() + if err != nil { + return nil, fmt.Errorf("Unable to find project store directory: %w", err) + } + p := path.Join(cd, "projects") + + db, err := leveldb.OpenFile(p, nil) + if err != nil { + return nil, fmt.Errorf("Unable to open project store: %w", err) + } + + has, err := db.Has(versTagKey, nil) + if err != nil { + return nil, err + } + setTag := !has + if has { + vers, err := db.Get(versTagKey, nil) + if err != nil { + return nil, err + } + if !bytes.Equal(vers, versTag) { + log.Warnf("patch store: version mismatch: wipe data") + iter := db.NewIterator(nil, nil) + for iter.Next() { + err := db.Delete(iter.Key(), nil) + if err != nil { + log.Errorf("delete: %v") + } + } + iter.Release() + setTag = true + } + } + + if setTag { + err := db.Put(versTagKey, versTag, nil) + if err != nil { + return nil, err + } + log.Infof("patch store: set version: %s", string(versTag)) + } + + return db, nil +} + +func encode(p models.Project) ([]byte, error) { + return json.Marshal(p) +} + +func decode(data []byte) (p models.Project, err error) { + err = json.Unmarshal(data, &p) + return +} + +func Store() models.PersistentStorer { + return &instance{} +} + +type instance struct{} + +func (instance) StoreProject(p models.Project, overwrite bool) error { + db, err := openStorage() + if err != nil { + return err + } + defer db.Close() + + key := createKey(p.Name) + has, err := db.Has(key, nil) + if err != nil { + return err + } + if has && !overwrite { + return fmt.Errorf("Project '%s' already exists.", p.Name) + } + + log.Debugf("project data: %v", p) + + encoded, err := encode(p) + if err != nil { + return err + } + return db.Put(key, encoded, nil) +} + +func (instance) DeleteProject(name string) error { + db, err := openStorage() + if err != nil { + return err + } + defer db.Close() + + key := createKey(name) + has, err := db.Has(key, nil) + if err != nil { + return err + } + if !has { + return fmt.Errorf("Project does not exist.") + } + return db.Delete(key, nil) +} + +func (instance) CurrentName() (string, error) { + db, err := openStorage() + if err != nil { + return "", err + } + defer db.Close() + cur, err := db.Get(currentKey, nil) + if err != nil { + return "", err + } + return parseKey(cur), nil +} + +func (instance) SetCurrent(name string) error { + db, err := openStorage() + if err != nil { + return err + } + defer db.Close() + key := createKey(name) + return db.Put(currentKey, key, nil) +} + +func (instance) Current() (models.Project, error) { + db, err := openStorage() + if err != nil { + return models.Project{}, err + } + defer db.Close() + + has, err := db.Has(currentKey, nil) + if err != nil { + return models.Project{}, err + } + if !has { + return models.Project{}, fmt.Errorf("No (current) project found; run 'project init' first.") + } + curProjectKey, err := db.Get(currentKey, nil) + if err != nil { + return models.Project{}, err + } + raw, err := db.Get(curProjectKey, nil) + if err != nil { + return models.Project{}, err + } + p, err := decode(raw) + if err != nil { + return models.Project{}, err + } + return p, nil +} + +func (instance) Names() ([]string, error) { + db, err := openStorage() + if err != nil { + return nil, err + } + defer db.Close() + var names []string + iter := db.NewIterator(nil, nil) + for iter.Next() { + if !isProjectKey(iter.Key()) { + continue + } + names = append(names, parseKey(iter.Key())) + } + iter.Release() + return names, nil +} + +func (instance) Project(name string) (models.Project, error) { + db, err := openStorage() + if err != nil { + return models.Project{}, err + } + defer db.Close() + raw, err := db.Get(createKey(name), nil) + if err != nil { + return models.Project{}, err + } + p, err := decode(raw) + if err != nil { + return models.Project{}, err + } + return p, nil +} + +func (instance) Projects() ([]models.Project, error) { + var projects []models.Project + db, err := openStorage() + if err != nil { + return nil, err + } + defer db.Close() + iter := db.NewIterator(nil, nil) + for iter.Next() { + if !isProjectKey(iter.Key()) { + continue + } + p, err := decode(iter.Value()) + if err != nil { + return nil, err + } + projects = append(projects, p) + } + iter.Release() + return projects, nil +} diff --git a/lib/pama/switch.go b/lib/pama/switch.go new file mode 100644 index 0000000..8190f3c --- /dev/null +++ b/lib/pama/switch.go @@ -0,0 +1,65 @@ +package pama + +import ( + "fmt" + "regexp" + "time" + + "git.sr.ht/~rjarry/aerc/lib/log" +) + +func (m PatchManager) SwitchProject(name string) error { + c, err := m.CurrentProject() + if err == nil { + if c.Name == name { + return nil + } + } + names, err := m.store().Names() + if err != nil { + return storeErr(err) + } + found := false + for _, n := range names { + if n == name { + found = true + break + } + } + if !found { + return fmt.Errorf("Project '%s' not found", name) + } + return storeErr(m.store().SetCurrent(name)) +} + +var switchDebouncer *time.Timer + +func DebouncedSwitchProject(name string) { + if switchDebouncer != nil { + if switchDebouncer.Stop() { + log.Debugf("pama: switch debounced") + } + } + if name == "" { + return + } + switchDebouncer = time.AfterFunc(500*time.Millisecond, func() { + if err := New().SwitchProject(name); err != nil { + log.Debugf("could not switch to project %s: %v", + name, err) + } else { + log.Debugf("project switch to project %s", name) + } + }) +} + +var fromSubject = regexp.MustCompile( + `\[\s*(RFC|DRAFT|[Dd]raft)*\s*(PATCH|[Pp]atch)\s+([^\s\]]+)\s*[vV]*[0-9/]*\s*\] `) + +func FromSubject(s string) string { + matches := fromSubject.FindStringSubmatch(s) + if len(matches) >= 3 { + return matches[3] + } + return "" +} diff --git a/lib/pama/switch_test.go b/lib/pama/switch_test.go new file mode 100644 index 0000000..04c72df --- /dev/null +++ b/lib/pama/switch_test.go @@ -0,0 +1,67 @@ +package pama_test + +import ( + "testing" + + "git.sr.ht/~rjarry/aerc/lib/pama" +) + +func TestFromSubject(t *testing.T) { + tests := []struct { + s string + want string + }{ + { + s: "[PATCH aerc] pama: new patch", + want: "aerc", + }, + { + s: "[PATCH aerc v2] pama: new patch", + want: "aerc", + }, + { + s: "[PATCH aerc 1/2] pama: new patch", + want: "aerc", + }, + { + s: "[Patch aerc] pama: new patch", + want: "aerc", + }, + { + s: "[patch aerc] pama: new patch", + want: "aerc", + }, + { + s: "[RFC PATCH aerc] pama: new patch", + want: "aerc", + }, + { + s: "[DRAFT PATCH aerc] pama: new patch", + want: "aerc", + }, + { + s: "RE: [PATCH aerc v1] pama: new patch", + want: "aerc", + }, + { + s: "[PATCH] pama: new patch", + want: "", + }, + { + s: "just a subject line", + want: "", + }, + { + s: "just a subject line with unrelated [asdf aerc v1]", + want: "", + }, + } + + for _, test := range tests { + got := pama.FromSubject(test.s) + if got != test.want { + t.Errorf("failed to get name from '%s': "+ + "got '%s', want '%s'", test.s, got, test.want) + } + } +} diff --git a/lib/pama/unlink.go b/lib/pama/unlink.go new file mode 100644 index 0000000..6a35e1a --- /dev/null +++ b/lib/pama/unlink.go @@ -0,0 +1,61 @@ +package pama + +import ( + "fmt" + + "git.sr.ht/~rjarry/aerc/lib/log" +) + +// Unlink removes provided project +func (m PatchManager) Unlink(name string) error { + store := m.store() + names, err := m.Names() + if err != nil { + return err + } + + index := -1 + for i, s := range names { + if s == name { + index = i + break + } + } + if index < 0 { + return fmt.Errorf("Project '%s' not found", name) + } + + cur, err := store.CurrentName() + if err == nil && cur == name { + var next string + for _, s := range names { + if name != s { + next = s + break + } + } + err = store.SetCurrent(next) + if err != nil { + return storeErr(err) + } + } + + p, err := store.Project(name) + if err == nil && isWorktree(p) { + err = m.deleteWorktree(p) + if err != nil { + log.Errorf("failed to delete worktree: %v", err) + } + err = store.SetCurrent(p.Worktree.Name) + if err != nil { + log.Errorf("failed to set current project: %v", err) + } + } + + return storeErr(m.store().DeleteProject(name)) +} + +func (m PatchManager) Names() ([]string, error) { + names, err := m.store().Names() + return names, storeErr(err) +} diff --git a/lib/pama/worktree.go b/lib/pama/worktree.go new file mode 100644 index 0000000..1718e43 --- /dev/null +++ b/lib/pama/worktree.go @@ -0,0 +1,88 @@ +package pama + +import ( + "fmt" + "os" + "path" + "path/filepath" + "strings" + + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/pama/models" + "git.sr.ht/~rjarry/aerc/lib/xdg" +) + +func cacheDir() (string, error) { + dir, err := os.UserCacheDir() + if err != nil { + dir = xdg.ExpandHome("~/.cache") + } + return path.Join(dir, "aerc"), nil +} + +func makeWorktreeName(baseProject, tag string) string { + unique, err := generateTag(4) + if err != nil { + log.Infof("could not generate unique id: %v", err) + } + return strings.Join([]string{baseProject, "worktree", tag, unique}, "_") +} + +func isWorktree(p models.Project) bool { + return p.Worktree.Name != "" && p.Worktree.Root != "" +} + +func (m PatchManager) CreateWorktree(p models.Project, commitID, tag string, +) (models.Project, error) { + var w models.Project + + if isWorktree(p) { + return w, fmt.Errorf("This is already a worktree.") + } + + w.RevctrlID = p.RevctrlID + w.Base = models.Commit{ID: commitID} + w.Name = makeWorktreeName(p.Name, tag) + w.Worktree = models.WorktreeParent{Name: p.Name, Root: p.Root} + + dir, err := cacheDir() + if err != nil { + return p, err + } + w.Root = filepath.Join(dir, "worktrees", w.Name) + + rc, err := m.rc(p.RevctrlID, p.Root) + if err != nil { + return p, revErr(err) + } + + err = rc.CreateWorktree(w.Root, w.Base.ID) + if err != nil { + return p, revErr(err) + } + + err = m.store().StoreProject(w, true) + if err != nil { + return p, storeErr(err) + } + + return w, nil +} + +func (m PatchManager) deleteWorktree(p models.Project) error { + if !isWorktree(p) { + return nil + } + + rc, err := m.rc(p.RevctrlID, p.Worktree.Root) + if err != nil { + return revErr(err) + } + + err = rc.DeleteWorktree(p.Root) + if err != nil { + return revErr(err) + } + + return nil +} diff --git a/lib/parse/ansi.go b/lib/parse/ansi.go new file mode 100644 index 0000000..12ea4c0 --- /dev/null +++ b/lib/parse/ansi.go @@ -0,0 +1,37 @@ +package parse + +import ( + "bufio" + "bytes" + "fmt" + "io" + "os" + "regexp" + + "git.sr.ht/~rjarry/aerc/lib/log" +) + +var AnsiReg = regexp.MustCompile("\x1B\\[[0-?]*[ -/]*[@-~]") + +// StripAnsi strips ansi escape codes from the reader +func StripAnsi(r io.Reader) io.Reader { + buf := bytes.NewBuffer(nil) + scanner := bufio.NewScanner(r) + scanner.Buffer(nil, 1024*1024*1024) + for scanner.Scan() { + line := scanner.Bytes() + line = AnsiReg.ReplaceAll(line, []byte("")) + _, err := buf.Write(line) + if err != nil { + log.Warnf("failed write ", err) + } + _, err = buf.Write([]byte("\n")) + if err != nil { + log.Warnf("failed write ", err) + } + } + if err := scanner.Err(); err != nil { + fmt.Fprintf(os.Stderr, "failed to read line: %v\n", err) + } + return buf +} diff --git a/lib/parse/daterange.go b/lib/parse/daterange.go new file mode 100644 index 0000000..2636860 --- /dev/null +++ b/lib/parse/daterange.go @@ -0,0 +1,471 @@ +package parse + +import ( + "fmt" + "strings" + "time" + + "git.sr.ht/~rjarry/aerc/lib/log" +) + +const dateFmt = "2006-01-02" + +// ParseDateRange parses a date range into a start and end date. Dates are +// expected to be in the YYYY-MM-DD format. +// +// Start and end dates are connected by the range operator ".." where end date +// is not included in the date range. +// +// ParseDateRange can also parse open-ended ranges, i.e. start.. or ..end are +// allowed. +// +// Relative date terms (such as "1 week 1 day" or "1w 1d") can be used, too. +func DateRange(s string) (start, end time.Time, err error) { + s = cleanInput(s) + s = ensureRangeOp(s) + i := strings.Index(s, "..") + switch { + case i < 0: + // single date + start, err = translate(s) + if err != nil { + err = fmt.Errorf("failed to parse date: %w", err) + return + } + end = start.AddDate(0, 0, 1) + + case i == 0: + // end date only + if len(s) < 2 { + err = fmt.Errorf("no date found") + return + } + end, err = translate(s[2:]) + if err != nil { + err = fmt.Errorf("failed to parse date: %w", err) + return + } + + case i > 0: + // start date first + start, err = translate(s[:i]) + if err != nil { + err = fmt.Errorf("failed to parse date: %w", err) + return + } + if len(s[i:]) <= 2 { + return + } + // and end dates if available + end, err = translate(s[(i + 2):]) + if err != nil { + err = fmt.Errorf("failed to parse date: %w", err) + return + } + } + + return +} + +type dictFunc = func(bool) time.Time + +// dict is a dictionary to translate words to dates. Map key must be at least 3 +// characters for matching purposes. +var dict map[string]dictFunc = map[string]dictFunc{ + "today": func(_ bool) time.Time { + return time.Now() + }, + "yesterday": func(_ bool) time.Time { + return time.Now().AddDate(0, 0, -1) + }, + "week": func(this bool) time.Time { + diff := 0 + if !this { + diff = -7 + } + return time.Now().AddDate(0, 0, + daydiff(time.Monday)+diff) + }, + "month": func(this bool) time.Time { + diff := 0 + if !this { + diff = -1 + } + t := time.Now() + return t.AddDate(0, diff, -t.Day()+1) + }, + "year": func(this bool) time.Time { + diff := 0 + if !this { + diff = -1 + } + t := time.Now() + return t.AddDate(diff, 0, -t.YearDay()+1) + }, + "monday": func(this bool) time.Time { + diff := 0 + if !this { + diff = -7 + } + return time.Now().AddDate(0, 0, + daydiff(time.Monday)+diff) + }, + "tuesday": func(this bool) time.Time { + diff := 0 + if !this { + diff = -7 + } + return time.Now().AddDate(0, 0, + daydiff(time.Tuesday)+diff) + }, + "wednesday": func(this bool) time.Time { + diff := 0 + if !this { + diff = -7 + } + return time.Now().AddDate(0, 0, + daydiff(time.Wednesday)+diff) + }, + "thursday": func(this bool) time.Time { + diff := 0 + if !this { + diff = -7 + } + return time.Now().AddDate(0, 0, + daydiff(time.Thursday)+diff) + }, + "friday": func(this bool) time.Time { + diff := 0 + if !this { + diff = -7 + } + return time.Now().AddDate(0, 0, + daydiff(time.Friday)+diff) + }, + "saturday": func(this bool) time.Time { + diff := 0 + if !this { + diff = -7 + } + return time.Now().AddDate(0, 0, + daydiff(time.Saturday)+diff) + }, + "sunday": func(this bool) time.Time { + diff := 0 + if !this { + diff = -7 + } + return time.Now().AddDate(0, 0, + daydiff(time.Sunday)+diff) + }, + "january": func(this bool) time.Time { + diff := 0 + if !this { + diff = -1 + } + t := time.Now() + return t.AddDate(diff, + monthdiff(time.January), -t.Day()+1) + }, + "february": func(this bool) time.Time { + diff := 0 + if !this { + diff = -1 + } + t := time.Now() + return t.AddDate(diff, + monthdiff(time.February), -t.Day()+1) + }, + "march": func(this bool) time.Time { + diff := 0 + if !this { + diff = -1 + } + t := time.Now() + return t.AddDate(diff, + monthdiff(time.March), -t.Day()+1) + }, + "april": func(this bool) time.Time { + diff := 0 + if !this { + diff = -1 + } + t := time.Now() + return t.AddDate(diff, + monthdiff(time.April), -t.Day()+1) + }, + "may": func(this bool) time.Time { + diff := 0 + if !this { + diff = -1 + } + t := time.Now() + return t.AddDate(diff, + monthdiff(time.May), -t.Day()+1) + }, + "june": func(this bool) time.Time { + diff := 0 + if !this { + diff = -1 + } + t := time.Now() + return t.AddDate(diff, + monthdiff(time.June), -t.Day()+1) + }, + "july": func(this bool) time.Time { + diff := 0 + if !this { + diff = -1 + } + t := time.Now() + return t.AddDate(diff, + monthdiff(time.July), -t.Day()+1) + }, + "august": func(this bool) time.Time { + diff := 0 + if !this { + diff = -1 + } + t := time.Now() + return t.AddDate(diff, + monthdiff(time.August), -t.Day()+1) + }, + "september": func(this bool) time.Time { + diff := 0 + if !this { + diff = -1 + } + t := time.Now() + return t.AddDate(diff, + monthdiff(time.September), -t.Day()+1) + }, + "october": func(this bool) time.Time { + diff := 0 + if !this { + diff = -1 + } + t := time.Now() + return t.AddDate(diff, + monthdiff(time.October), -t.Day()+1) + }, + "november": func(this bool) time.Time { + diff := 0 + if !this { + diff = -1 + } + t := time.Now() + return t.AddDate(diff, + monthdiff(time.November), -t.Day()+1) + }, + "december": func(this bool) time.Time { + diff := 0 + if !this { + diff = -1 + } + t := time.Now() + return t.AddDate(diff, + monthdiff(time.December), -t.Day()+1) + }, +} + +func daydiff(d time.Weekday) int { + daydiff := d - time.Now().Weekday() + if daydiff > 0 { + return int(daydiff) - 7 + } + return int(daydiff) +} + +func monthdiff(d time.Month) int { + monthdiff := d - time.Now().Month() + if monthdiff > 0 { + return int(monthdiff) - 12 + } + return int(monthdiff) +} + +// translate translates regular time words into date strings +func translate(s string) (time.Time, error) { + if s == "" { + return time.Now(), fmt.Errorf("empty string") + } + log.Tracef("input: %s", s) + s0 := s + + // if next characters is integer, then parse a relative date + if '0' <= s[0] && s[0] <= '9' && hasUnit(s) { + relDate, err := RelativeDate(s) + if err != nil { + log.Errorf("could not parse relative date from '%s': %v", + s0, err) + } else { + log.Tracef("relative date: translated to %v from %s", + relDate, s0) + return bod(relDate.Apply(time.Now())), nil + } + } + + // consult dictionary for terms translation + s, this, hasPrefix := handlePrefix(s) + for term, dateFn := range dict { + if term == "month" && !hasPrefix { + continue + } + if strings.Contains(term, s) { + log.Tracef("dictionary: translated to %s from %s", + term, s0) + return bod(dateFn(this)), nil + } + } + + // this is a regular date, parse it in the normal format + log.Infof("parse: translates %s to regular format", s0) + return time.Parse(dateFmt, s) +} + +// bod returns the begin of the day +func bod(t time.Time) time.Time { + y, m, d := t.Date() + return time.Date(y, m, d, 0, 0, 0, 0, t.Location()) +} + +func handlePrefix(s string) (string, bool, bool) { + var hasPrefix bool + this := true + if strings.HasPrefix(s, "this") { + hasPrefix = true + s = strings.TrimPrefix(s, "this") + } + if strings.HasPrefix(s, "last") { + hasPrefix = true + this = false + s = strings.TrimPrefix(s, "last") + } + return s, this, hasPrefix +} + +func cleanInput(s string) string { + s = strings.ToLower(s) + s = strings.ReplaceAll(s, " ", "") + s = strings.ReplaceAll(s, "_", "") + return s +} + +// RelDate is the relative date in the past, e.g. yesterday would be +// represented as RelDate{0,0,1}. +type RelDate struct { + Year uint + Month uint + Day uint +} + +func (d RelDate) Apply(t time.Time) time.Time { + return t.AddDate(-int(d.Year), -int(d.Month), -int(d.Day)) +} + +// ParseRelativeDate parses a string of relative terms into a DateAdd. +// +// Syntax: N (year|month|week|day) .. +// +// The following are valid inputs: +// 5weeks1day +// 5w1d +// +// Adapted from the Go stdlib in src/time/format.go +func RelativeDate(s string) (RelDate, error) { + s0 := s + s = cleanInput(s) + var da RelDate + for s != "" { + var n uint + + var err error + + // expect an integer + if !('0' <= s[0] && s[0] <= '9') { + return da, fmt.Errorf("not a valid relative term: %s", + s0) + } + + // consume integer + n, s, err = leadingInt(s) + if err != nil { + return da, fmt.Errorf("cannot read integer in %s", + s0) + } + + // consume the units + i := 0 + for ; i < len(s); i++ { + c := s[i] + if '0' <= c && c <= '9' { + break + } + } + if i == 0 { + return da, fmt.Errorf("missing unit in %s", s0) + } + + u := s[:i] + s = s[i:] + switch u[0] { + case 'y': + da.Year += n + case 'm': + da.Month += n + case 'w': + da.Day += 7 * n + case 'd': + da.Day += n + default: + return da, fmt.Errorf("unknown unit %s in %s", u, s0) + } + + } + + return da, nil +} + +func hasUnit(s string) (has bool) { + for _, u := range "ymwd" { + if strings.Contains(s, string(u)) { + return true + } + } + return false +} + +// leadingInt parses and returns the leading integer in s. +// +// Adapted from the Go stdlib in src/time/format.go +func leadingInt(s string) (x uint, rem string, err error) { + i := 0 + for ; i < len(s); i++ { + c := s[i] + if c < '0' || c > '9' { + break + } + x = x*10 + uint(c) - '0' + } + return x, s[i:], nil +} + +func ensureRangeOp(s string) string { + if strings.Contains(s, "..") { + return s + } + s0 := s + for _, m := range []string{"this", "last"} { + for _, u := range []string{"year", "month", "week"} { + term := m + u + if strings.Contains(s, term) { + if m == "last" { + return s0 + "..this" + u + } else { + return s0 + ".." + } + } + } + } + return s0 +} diff --git a/lib/parse/daterange_test.go b/lib/parse/daterange_test.go new file mode 100644 index 0000000..ff2ae07 --- /dev/null +++ b/lib/parse/daterange_test.go @@ -0,0 +1,97 @@ +package parse_test + +import ( + "reflect" + "testing" + "time" + + "git.sr.ht/~rjarry/aerc/lib/parse" +) + +func TestParseDateRange(t *testing.T) { + dateFmt := "2006-01-02" + date := func(s string) time.Time { d, _ := time.Parse(dateFmt, s); return d } + tests := []struct { + s string + start time.Time + end time.Time + }{ + { + s: "2022-11-01", + start: date("2022-11-01"), + end: date("2022-11-02"), + }, + { + s: "2022-11-01..", + start: date("2022-11-01"), + }, + { + s: "..2022-11-05", + end: date("2022-11-05"), + }, + { + s: "2022-11-01..2022-11-05", + start: date("2022-11-01"), + end: date("2022-11-05"), + }, + } + + for _, test := range tests { + start, end, err := parse.DateRange(test.s) + if err != nil { + t.Errorf("ParseDateRange return error for %s: %v", + test.s, err) + } + + if !start.Equal(test.start) { + t.Errorf("wrong start date; expected %v, got %v", + test.start, start) + } + + if !end.Equal(test.end) { + t.Errorf("wrong end date; expected %v, got %v", + test.end, end) + } + } +} + +func TestParseRelativeDate(t *testing.T) { + tests := []struct { + s string + want parse.RelDate + }{ + { + s: "5 weeks 1 day", + want: parse.RelDate{Year: 0, Month: 0, Day: 5*7 + 1}, + }, + { + s: "5_weeks 1_day", + want: parse.RelDate{Year: 0, Month: 0, Day: 5*7 + 1}, + }, + { + s: "5weeks1day", + want: parse.RelDate{Year: 0, Month: 0, Day: 5*7 + 1}, + }, + { + s: "5w1d", + want: parse.RelDate{Year: 0, Month: 0, Day: 5*7 + 1}, + }, + { + s: "5y4m3w1d", + want: parse.RelDate{Year: 5, Month: 4, Day: 3*7 + 1}, + }, + } + + for _, test := range tests { + da, err := parse.RelativeDate(test.s) + if err != nil { + t.Errorf("ParseRelativeDate return error for %s: %v", + test.s, err) + } + + if !reflect.DeepEqual(da, test.want) { + t.Errorf("results don't match. expected %v, got %v", + test.want, da) + } + } +} diff --git a/lib/parse/header.go b/lib/parse/header.go new file mode 100644 index 0000000..522d178 --- /dev/null +++ b/lib/parse/header.go @@ -0,0 +1,42 @@ +package parse + +import ( + "strings" + + "git.sr.ht/~rjarry/aerc/lib/log" + "github.com/emersion/go-message/mail" +) + +// MsgIDList parses a list of message identifiers. It returns message +// identifiers without angle brackets. If the header field is missing, +// it returns nil. +// +// This can be used on In-Reply-To and References header fields. +// If the field does not conform to RFC 5322, fall back +// to greedily parsing a subsequence of the original field. +func MsgIDList(h *mail.Header, key string) []string { + l, err := h.MsgIDList(key) + if err == nil { + return l + } + log.Errorf("%s: %s", err, h.Get(key)) + + // Expensive, fix your peer's MUA instead! + var list []string + header := &mail.Header{Header: h.Header.Copy()} + value := header.Get(key) + for err != nil && len(value) > 0 { + // Skip parsed IDs + if len(l) > 0 { + last := "<" + l[len(l)-1] + ">" + value = value[strings.Index(value, last)+len(last):] + list = append(list, l...) + } + + // Skip a character until some IDs can be parsed + value = value[1:] + header.Set(key, value) + l, err = header.MsgIDList(key) + } + return append(list, l...) +} diff --git a/lib/parse/header_test.go b/lib/parse/header_test.go new file mode 100644 index 0000000..bc6e363 --- /dev/null +++ b/lib/parse/header_test.go @@ -0,0 +1,42 @@ +package parse_test + +import ( + "testing" + + "git.sr.ht/~rjarry/aerc/lib/parse" + "github.com/emersion/go-message/mail" + "github.com/stretchr/testify/assert" +) + +func TestMsgIDList(t *testing.T) { + tests := []struct { + name string + input string + expected []string + }{ + { + name: "valid", + input: "<1q@az> (cmt)\r\n <2w@sx> (khld)", + expected: []string{"1q@az", "2w@sx"}, + }, + { + name: "comma", + input: "<3e@dc>, <4r@fv>,\t<5t@gb>", + expected: []string{"3e@dc", "4r@fv", "5t@gb"}, + }, + { + name: "other non-CFWS separators", + input: "<6y@>, <hn@7u>\n <> <jm@8i>", + expected: []string{"hn@7u", "jm@8i"}, + }, + } + + for _, test := range tests { + var h mail.Header + h.Set("References", test.input) + t.Run(test.name, func(t *testing.T) { + actual := parse.MsgIDList(&h, "References") + assert.Equal(t, test.expected, actual) + }) + } +} diff --git a/lib/parse/hyperlinks.go b/lib/parse/hyperlinks.go new file mode 100644 index 0000000..1200f93 --- /dev/null +++ b/lib/parse/hyperlinks.go @@ -0,0 +1,129 @@ +package parse + +import ( + "bytes" + "io" + "regexp" + "sort" +) + +// Partial regexp to match the beginning of URLs and email addresses. +// The remainder of the matched URLs/emails is parsed manually. +var urlRe = regexp.MustCompile( + `([a-z]{2,8})://` + // URL start + `|` + // or + `(mailto:)?[[:alnum:]_+.~/-]*[[:alnum:]]@`, // email start +) + +// HttpLinks searches a reader for a http link and returns a copy of the +// reader and a slice with links. +func HttpLinks(r io.Reader) (io.Reader, []string) { + buf, err := io.ReadAll(r) + if err != nil { + return r, nil + } + + links := make(map[string]bool) + b := buf + match := urlRe.FindSubmatchIndex(b) + for ; match != nil; match = urlRe.FindSubmatchIndex(b) { + // Regular expressions do not really cut it here and we + // need to detect opening/closing braces to handle + // markdown link syntax. + var paren, bracket, ltgt, scheme int + var emitUrl bool + i, j := match[0], match[1] + b = b[i:] + scheme = j - i + j = scheme + + // "inline" email without a mailto: prefix - add some extra checks for those + inlineEmail := len(match) > 4 && match[2] == -1 && match[4] == -1 + + for !emitUrl && j < len(b) && bytes.IndexByte(urichars, b[j]) != -1 { + switch b[j] { + case '[': + bracket++ + j++ + case '(': + paren++ + j++ + case '<': + ltgt++ + j++ + case ']': + bracket-- + if bracket < 0 { + emitUrl = true + } else { + j++ + } + case ')': + paren-- + if paren < 0 { + emitUrl = true + } else { + j++ + } + case '>': + ltgt-- + if ltgt < 0 { + emitUrl = true + } else { + j++ + } + case '&': + if inlineEmail { + emitUrl = true + } else { + j++ + } + default: + j++ + } + + // we don't want those in inline emails + if inlineEmail && (paren > 0 || ltgt > 0 || bracket > 0) { + j-- + emitUrl = true + } + } + + // Heuristic to remove trailing characters that are + // valid URL characters, but typically not at the end of + // the URL + for trim := true; trim && j > 0; { + switch b[j-1] { + case '.', ',', ':', ';', '?', '!', '"', '\'', '%': + j-- + default: + trim = false + } + } + if j == scheme { + // Only an URL scheme, ignore. + b = b[j:] + continue + } + url := string(b[:j]) + if inlineEmail { + // Email address with missing mailto: scheme. Add it. + url = "mailto:" + url + } + links[url] = true + b = b[j:] + } + + results := make([]string, 0, len(links)) + for link := range links { + results = append(results, link) + } + sort.Strings(results) + + return bytes.NewReader(buf), results +} + +var urichars = []byte( + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + + "0123456789-_.,~:;/?#@!$&%*+=\"'<>()[]", +) diff --git a/lib/parse/hyperlinks_test.go b/lib/parse/hyperlinks_test.go new file mode 100644 index 0000000..00a0676 --- /dev/null +++ b/lib/parse/hyperlinks_test.go @@ -0,0 +1,162 @@ +package parse_test + +import ( + "io" + "strings" + "testing" + + "git.sr.ht/~rjarry/aerc/lib/parse" +) + +func TestHyperlinks(t *testing.T) { + tests := []struct { + name string + text string + links []string + }{ + { + name: "http-link", + text: "http://aerc-mail.org", + links: []string{"http://aerc-mail.org"}, + }, + { + name: "https-link", + text: "https://aerc-mail.org", + links: []string{"https://aerc-mail.org"}, + }, + { + name: "https-link-in-text", + text: "text https://aerc-mail.org more text", + links: []string{"https://aerc-mail.org"}, + }, + { + name: "https-link-in-parenthesis", + text: "text (https://aerc-mail.org) more text", + links: []string{"https://aerc-mail.org"}, + }, + { + name: "https-link-in-quotes", + text: "text \"https://aerc-mail.org\" more text", + links: []string{"https://aerc-mail.org"}, + }, + { + name: "https-link-in-angle-brackets", + text: "text <https://aerc-mail.org> more text", + links: []string{"https://aerc-mail.org"}, + }, + { + name: "https-link-in-html", + text: "<a href=\"https://aerc-mail.org\">", + links: []string{"https://aerc-mail.org"}, + }, + { + name: "https-link-twice", + text: "text https://aerc-mail.org more text https://aerc-mail.org more text", + links: []string{"https://aerc-mail.org"}, + }, + { + name: "https-link-markdown", + text: "text [https://aerc-mail.org](https://aerc-mail.org) more text", + links: []string{"https://aerc-mail.org"}, + }, + { + name: "multiple-links", + text: "text https://aerc-mail.org more text http://git.sr.ht/~rjarry/aerc more text", + links: []string{"https://aerc-mail.org", "http://git.sr.ht/~rjarry/aerc"}, + }, + { + name: "rfc", + text: "text http://www.ietf.org/rfc/rfc2396.txt more text", + links: []string{"http://www.ietf.org/rfc/rfc2396.txt"}, + }, + { + name: "http-with-query-and-fragment", + text: "text <http://example.com:8042/over/there?name=ferret#nose> more text", + links: []string{"http://example.com:8042/over/there?name=ferret#nose"}, + }, + { + name: "http-with-at", + text: "text http://cnn.example.com&story=breaking_news@10.0.0.1/top_story.htm more text", + links: []string{"http://cnn.example.com&story=breaking_news@10.0.0.1/top_story.htm"}, + }, + { + name: "https-with-fragment", + text: "text https://www.ics.uci.edu/pub/ietf/uri/#Related more text", + links: []string{"https://www.ics.uci.edu/pub/ietf/uri/#Related"}, + }, + { + name: "https-with-query", + text: "text https://www.example.com/index.php?id_sezione=360&sid=3a5ebc944f41daa6f849f730f1 more text", + links: []string{"https://www.example.com/index.php?id_sezione=360&sid=3a5ebc944f41daa6f849f730f1"}, + }, + { + name: "https-onedrive", + text: "I have a link like this in an email (I deleted a few characters here-and-there for privacy) https://1drv.ms/w/s!Ap-KLfhNxS4fRt6tIvw?e=dW8WLO", + links: []string{"https://1drv.ms/w/s!Ap-KLfhNxS4fRt6tIvw?e=dW8WLO"}, + }, + { + name: "email", + text: "You can reach me via the somewhat strange, but nonetheless valid, email foo@baz.com", + links: []string{"mailto:foo@baz.com"}, + }, + { + name: "mailto", + text: "You can reach me via the somewhat strange, but nonetheless valid, email mailto:bar@fooz.fr. Thank you", + links: []string{"mailto:bar@fooz.fr"}, + }, + { + name: "mailto-ipv6", + text: "You can reach me via the somewhat strange, but nonetheless valid, email mailto:~mpldr/list@[2001:db8::7]", + links: []string{"mailto:~mpldr/list@[2001:db8::7]"}, + }, + { + name: "mailto-ipv6-query", + text: "You can reach me via the somewhat strange, but nonetheless valid, email mailto:~mpldr/list@[2001:db8::7]?subject=whazzup%3F", + links: []string{"mailto:~mpldr/list@[2001:db8::7]?subject=whazzup%3F"}, + }, + { + name: "simple email in <a href>", + text: `<a href="mailto:a@abc.com" rel="noopener noreferrer">`, + links: []string{"mailto:a@abc.com"}, + }, + { + name: "simple email in <a> body", + text: `<a href="#" rel="noopener noreferrer">a@abc.com</a><br/><p>more text</p>`, + links: []string{"mailto:a@abc.com"}, + }, + { + name: "emails in <a> href and body", + text: `<a href="mailto:a@abc.com" rel="noopener noreferrer">b@abc.com</a><br/><p>more text</p>`, + links: []string{"mailto:a@abc.com", "mailto:b@abc.com"}, + }, + { + name: "email in <...>", + text: `<div>01.02.2023, 10:11, "Firstname Lastname" <a@abc.com>:</div>`, + links: []string{"mailto:a@abc.com"}, + }, + } + + for i, test := range tests { + t.Run(test.name, func(t *testing.T) { + // make sure reader is exact copy of input reader + reader, parsedLinks := parse.HttpLinks(strings.NewReader(test.text)) + if _, err := io.ReadAll(reader); err != nil { + t.Skipf("could not read text: %v", err) + } + + // check correct parsed links + if len(parsedLinks) != len(test.links) { + t.Errorf("different number of links: got %d but expected %d", len(parsedLinks), len(test.links)) + } + linkMap := make(map[string]struct{}) + for _, got := range parsedLinks { + linkMap[got] = struct{}{} + } + for _, expected := range test.links { + if _, ok := linkMap[expected]; !ok { + t.Errorf("link[%d] not parsed: %s", i, expected) + } + } + }) + } +} diff --git a/lib/parse/match.go b/lib/parse/match.go new file mode 100644 index 0000000..dc5a06c --- /dev/null +++ b/lib/parse/match.go @@ -0,0 +1,30 @@ +package parse + +import ( + "regexp" + "sync" + + "git.sr.ht/~rjarry/aerc/lib/log" +) + +var reCache sync.Map + +// Check if a string matches the specified regular expression. +// The regexp is compiled only once and stored in a cache for future use. +func MatchCache(s, expr string) bool { + var re interface{} + var found bool + + if re, found = reCache.Load(expr); !found { + var err error + re, err = regexp.Compile(expr) + if err != nil { + log.Errorf("`%s` invalid regexp: %s", expr, err) + } + reCache.Store(expr, re) + } + if re, ok := re.(*regexp.Regexp); ok && re != nil { + return re.MatchString(s) + } + return false +} diff --git a/lib/pinentry/pinentry.go b/lib/pinentry/pinentry.go new file mode 100644 index 0000000..51b5492 --- /dev/null +++ b/lib/pinentry/pinentry.go @@ -0,0 +1,71 @@ +package pinentry + +import ( + "fmt" + "os" + "os/exec" + "strings" + "sync/atomic" + + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/ui" +) + +var pinentryMode int32 = 0 + +func Enable() { + if !config.General.UsePinentry { + return + } + if atomic.SwapInt32(&pinentryMode, 1) == 1 { + // cannot enter pinentry mode twice + return + } + ui.SuspendScreen() +} + +func Disable() { + if atomic.SwapInt32(&pinentryMode, 0) == 0 { + // not in pinentry mode + return + } + ui.ResumeScreen() +} + +func SetCmdEnv(cmd *exec.Cmd) { + if cmd == nil || atomic.LoadInt32(&pinentryMode) == 0 { + return + } + + env := cmd.Env + if env == nil { + env = os.Environ() + } + + hasTerm := false + hasGPGTTY := false + for _, e := range env { + switch { + case strings.HasPrefix(strings.ToUpper(e), "TERM="): + log.Debugf("pinentry: use %v", e) + hasTerm = true + case strings.HasPrefix(strings.ToUpper(e), "GPG_TTY="): + log.Debugf("pinentry: use %v", e) + hasGPGTTY = true + } + } + + if !hasTerm { + env = append(env, "TERM=xterm-256color") + log.Debugf("pinentry: set TERM=xterm-256color") + } + + if !hasGPGTTY { + tty := ttyname() + env = append(env, fmt.Sprintf("GPG_TTY=%s", tty)) + log.Debugf("pinentry: set GPG_TTY=%s", tty) + } + + cmd.Env = env +} diff --git a/lib/pinentry/ttyname.go b/lib/pinentry/ttyname.go new file mode 100644 index 0000000..053cff7 --- /dev/null +++ b/lib/pinentry/ttyname.go @@ -0,0 +1,45 @@ +package pinentry + +import ( + "fmt" + "os" + "strings" + + "git.sr.ht/~rjarry/aerc/lib/log" +) + +var missingGPGTTYmsg = ` +You need to set GPG_TTY manually before starting aerc. Add the following to your +.bashrc or whatever initialization file is used for shell invocations: + + GPG_TTY=$(tty) + export GPG_TTY + +Further information can be found here: +https://www.gnupg.org/documentation/manuals/gnupg/Invoking-GPG_002dAGENT.html +` + +// ttyname returns current name of the pty. This is necessary in order to tell +// pinentry where to ask for the passphrase. +// +// If there is a GPG_TTY environment variable set, use this one. Otherwise, try +// readline() on /proc/<pid>/fd/0. +// +// If both approaches fail, the user's only option is to set GPG_TTY manually. +// +// If tty name could not be determined, an empty string is returned. +func ttyname() string { + if s := os.Getenv("GPG_TTY"); s != "" { + return s + } + + // try readlink or else show missing GPG_TTY warning msg + tty, err := os.Readlink(fmt.Sprintf("/proc/%d/fd/0", os.Getpid())) + if err != nil { + log.Debugf("readlink: '%s' with err: %v", tty, err) + log.Warnf(missingGPGTTYmsg) + return "" + } + + return strings.TrimSpace(tty) +} diff --git a/lib/rfc822/message.go b/lib/rfc822/message.go new file mode 100644 index 0000000..ec40954 --- /dev/null +++ b/lib/rfc822/message.go @@ -0,0 +1,466 @@ +package rfc822 + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "io" + "mime" + "regexp" + "strings" + "time" + + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/parse" + "git.sr.ht/~rjarry/aerc/models" + "github.com/emersion/go-message" + _ "github.com/emersion/go-message/charset" + "github.com/emersion/go-message/mail" +) + +type MultipartError struct { + e error +} + +func (u MultipartError) Unwrap() error { return u.e } + +func (u MultipartError) Error() string { + return "multipart error: " + u.e.Error() +} + +// IsMultipartError returns a boolean indicating whether the error is known to +// report that the multipart message is malformed and could not be parsed. +func IsMultipartError(err error) bool { + return errors.As(err, new(MultipartError)) +} + +// RFC 1123Z regexp +var dateRe = regexp.MustCompile(`(((Mon|Tue|Wed|Thu|Fri|Sat|Sun))[,]?\s[0-9]{1,2})\s` + + `(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s` + + `([0-9]{4})\s([0-9]{2}):([0-9]{2})(:([0-9]{2}))?\s([\+|\-][0-9]{4})`) + +func FetchEntityPartReader(e *message.Entity, index []int) (io.Reader, error) { + if len(index) == 0 { + // non multipart, simply return everything + return bufReader(e) + } + if mpr := e.MultipartReader(); mpr != nil { + idx := 0 + for { + idx++ + part, err := mpr.NextPart() + switch { + case message.IsUnknownCharset(err): + log.Warnf("FetchEntityPartReader: %v", err) + case message.IsUnknownEncoding(err): + log.Warnf("FetchEntityPartReader: %v", err) + case err != nil: + log.Warnf("FetchEntityPartReader: %v", err) + return bufReader(e) + } + if idx == index[0] { + rest := index[1:] + if len(rest) < 1 { + return bufReader(part) + } + return FetchEntityPartReader(part, index[1:]) + } + } + } + return nil, fmt.Errorf("FetchEntityPartReader: unexpected code reached") +} + +// TODO: the UI doesn't seem to like readers which aren't buffers +func bufReader(e *message.Entity) (io.Reader, error) { + var buf bytes.Buffer + if _, err := io.Copy(&buf, e.Body); err != nil { + return nil, err + } + return &buf, nil +} + +// split a MIME type into its major and minor parts +func splitMIME(m string) (string, string) { + parts := strings.Split(m, "/") + if len(parts) != 2 { + return parts[0], "" + } + return parts[0], parts[1] +} + +func fixContentType(h message.Header) (string, map[string]string) { + ct, rest := h.Get("Content-Type"), "" + if i := strings.Index(ct, ";"); i > 0 { + ct, rest = ct[:i], ct[i:] + } + + // check if there are quotes around the content type + if strings.Contains(ct, "\"") { + header := strings.ReplaceAll(ct, "\"", "") + if rest != "" { + header += rest + } + h.Set("Content-Type", header) + if contenttype, params, err := h.ContentType(); err == nil { + return contenttype, params + } + } + + // if all else fails, return text/plain + return "text/plain", nil +} + +// ParseEntityStructure will parse the message and create a multipart structure +// for multipart messages. Parsing is done on a best-efforts basis: +// +// If the content-type cannot be parsed, ParseEntityStructure will try to fix +// it; otherwise, it returns a text/plain mime type as a fallback. No error will +// be returned. +// +// If a charset or encoding error is encountered for a message part of a +// multipart message, the error is logged and ignored. In those cases, we still +// get a valid message body but the content is just not decoded or converted. No +// error will be returned. +// +// If reading a multipart message fails, ParseEntityStructure will return a +// multipart error. This error indicates that this message is malformed and +// there is nothing more we can do. The caller is then advised to use a single +// text/plain body structure using CreateTextPlainPart(). +func ParseEntityStructure(e *message.Entity) (*models.BodyStructure, error) { + var body models.BodyStructure + contentType, ctParams, err := e.Header.ContentType() + if err != nil { + // try to fix the error; if all measures fail, then return a + // text/plain content type to display at least plaintext + contentType, ctParams = fixContentType(e.Header) + } + + mimeType, mimeSubType := splitMIME(contentType) + body.MIMEType = mimeType + body.MIMESubType = mimeSubType + body.Params = ctParams + body.Description = e.Header.Get("content-description") + body.Encoding = e.Header.Get("content-transfer-encoding") + if cd := e.Header.Get("content-disposition"); cd != "" { + contentDisposition, cdParams, err := e.Header.ContentDisposition() + if err != nil { + return nil, fmt.Errorf("could not parse content disposition: %w", err) + } + body.Disposition = contentDisposition + body.DispositionParams = cdParams + } + body.Parts = []*models.BodyStructure{} + if mpr := e.MultipartReader(); mpr != nil { + for { + part, err := mpr.NextPart() + switch { + case errors.Is(err, io.EOF): + return &body, nil + case message.IsUnknownCharset(err): + log.Warnf("ParseEntityStructure: %v", err) + case message.IsUnknownEncoding(err): + log.Warnf("ParseEntityStructure: %v", err) + case err != nil: + return nil, MultipartError{err} + } + ps, err := ParseEntityStructure(part) + if err != nil { + return nil, fmt.Errorf("could not parse child entity structure: %w", err) + } + body.Parts = append(body.Parts, ps) + } + } + return &body, nil +} + +// CreateTextPlainBody creates a plain-vanilla text/plain body structure. +func CreateTextPlainBody() *models.BodyStructure { + body := &models.BodyStructure{} + body.MIMEType = "text" + body.MIMESubType = "plain" + body.Params = map[string]string{"charset": "utf-8"} + body.Parts = []*models.BodyStructure{} + return body +} + +func parseEnvelope(h *mail.Header) *models.Envelope { + subj, err := h.Subject() + if err != nil { + log.Errorf("could not decode subject: %v", err) + subj = h.Get("Subject") + } + msgID, err := h.MessageID() + if err != nil { + log.Errorf("invalid Message-ID header: %v", err) + // proper parsing failed, so fall back to whatever is there + msgID = strings.Trim(h.Get("message-id"), "<>") + } + var irt string + irtList := parse.MsgIDList(h, "in-reply-to") + if len(irtList) > 0 { + irt = irtList[0] + } + date, err := parseDate(h) + if err != nil { + // if only the date parsing failed we still get the rest of the + // envelop structure in a valid state. + // Date parsing errors are fairly common and it's better to be + // slightly off than to not be able to read the mails at all + // hence we continue here + log.Errorf("invalid Date header: %v", err) + } + return &models.Envelope{ + Date: date, + Subject: subj, + MessageId: msgID, + From: parseAddressList(h, "from"), + ReplyTo: parseAddressList(h, "reply-to"), + Sender: parseAddressList(h, "sender"), + To: parseAddressList(h, "to"), + Cc: parseAddressList(h, "cc"), + Bcc: parseAddressList(h, "bcc"), + InReplyTo: irt, + } +} + +// If the date is formatted like ...... -0500 (EST), parser takes the EST part +// and ignores the numeric offset. Then it might easily fail to guess what EST +// means unless the proper locale is loaded. This function checks that, so such +// time values can be safely ignored +// https://stackoverflow.com/questions/49084316/why-doesnt-gos-time-parse-parse-the-timezone-identifier +func isDateOK(t time.Time) bool { + name, offset := t.Zone() + + // non-zero offsets are fine + if offset != 0 { + return true + } + + // zero offset is ok if that's UTC or GMT + if name == "UTC" || name == "GMT" || name == "" { + return true + } + + // otherwise this date should not be trusted + return false +} + +// parseDate tries to parse the date from the Date header with non std formats +// if this fails it tries to parse the received header as well +func parseDate(h *mail.Header) (time.Time, error) { + // here we store the best parsed time we have so far + // if we find no "correct" time, we'll use that + bestDate := time.Time{} + + // trying the easy way + t, err := h.Date() + if err == nil { + if isDateOK(t) { + return t, nil + } + bestDate = t + } + text := h.Get("date") + + // sometimes, no error occurs but the date is empty. + // In this case, guess time from received header field + if text == "" { + t, err := parseReceivedHeader(h) + if err == nil { + return t, nil + } + } + layouts := []string{ + // X-Mailer: EarthLink Zoo Mail 1.0 + "Mon, _2 Jan 2006 15:04:05 -0700 (GMT-07:00)", + } + for _, layout := range layouts { + if t, err := time.Parse(layout, text); err == nil { + if isDateOK(t) { + return t, nil + } + bestDate = t + } + } + + // still no success, try the received header + t, err = parseReceivedHeader(h) + if err == nil { + if isDateOK(t) { + return t, nil + } + bestDate = t + } + + // do we have at least something? + if !bestDate.IsZero() { + return bestDate, nil + } + + // sad... + return time.Time{}, fmt.Errorf("unrecognized date format: %s", text) +} + +func parseReceivedHeader(h *mail.Header) (time.Time, error) { + guess, err := h.Text("received") + if err != nil { + return time.Time{}, fmt.Errorf("received header not parseable: %w", + err) + } + return time.Parse(time.RFC1123Z, dateRe.FindString(guess)) +} + +func parseAddressList(h *mail.Header, key string) []*mail.Address { + addrs, err := h.AddressList(key) + if len(addrs) == 0 { + // Only consider the error if the returned address list is empty + // Sometimes, we get a list of addresses and unknown charset + // errors which are not fatal. + if val := h.Get(key); val != "" { + if err != nil { + log.Errorf("%s: %s: %v", key, val, err) + } + // Header value is not empty but parsing completely + // failed. Return something so that the message can at + // least be displayed. + return []*mail.Address{{Name: val}} + } + return nil + } + for _, addr := range addrs { + // Handle invalid headers with quoted *AND* encoded names + if strings.HasPrefix(addr.Name, "=?") && strings.HasSuffix(addr.Name, "?=") { + d := mime.WordDecoder{CharsetReader: message.CharsetReader} + addr.Name, _ = d.DecodeHeader(addr.Name) + } + } + // If we got at least one address, ignore any returned error. + return addrs +} + +// RawMessage is an interface that describes a raw message +type RawMessage interface { + NewReader() (io.ReadCloser, error) + ModelFlags() (models.Flags, error) + Labels() ([]string, error) + UID() models.UID +} + +// MessageInfo populates a models.MessageInfo struct for the message. +// based on the reader returned by NewReader +func MessageInfo(raw RawMessage) (*models.MessageInfo, error) { + var parseErr error + r, err := raw.NewReader() + if err != nil { + return nil, err + } + defer r.Close() + msg, err := ReadMessage(r) + if err != nil { + return nil, fmt.Errorf("could not read message: %w", err) + } + bs, err := ParseEntityStructure(msg) + if IsMultipartError(err) { + log.Warnf("multipart error: %v", err) + bs = CreateTextPlainBody() + } else if err != nil { + return nil, fmt.Errorf("could not get structure: %w", err) + } + h := &mail.Header{Header: msg.Header} + env := parseEnvelope(h) + recDate, _ := parseReceivedHeader(h) + if recDate.IsZero() { + // better than nothing, if incorrect + recDate = env.Date + } + flags, err := raw.ModelFlags() + if err != nil { + return nil, err + } + labels, err := raw.Labels() + if err != nil { + return nil, err + } + return &models.MessageInfo{ + BodyStructure: bs, + Envelope: env, + Flags: flags, + Labels: labels, + InternalDate: recDate, + RFC822Headers: h, + Size: 0, + Uid: raw.UID(), + Error: parseErr, + }, nil +} + +// MessageHeaders populates a models.MessageInfo struct for the message. +// based on the reader returned by NewReader. Minimal information is included. +// There is no body structure or RFC822Headers set +func MessageHeaders(raw RawMessage) (*models.MessageInfo, error) { + var parseErr error + r, err := raw.NewReader() + if err != nil { + return nil, err + } + defer r.Close() + msg, err := ReadMessage(r) + if err != nil { + return nil, fmt.Errorf("could not read message: %w", err) + } + h := &mail.Header{Header: msg.Header} + env := parseEnvelope(h) + recDate, _ := parseReceivedHeader(h) + if recDate.IsZero() { + // better than nothing, if incorrect + recDate = env.Date + } + flags, err := raw.ModelFlags() + if err != nil { + return nil, err + } + labels, err := raw.Labels() + if err != nil { + return nil, err + } + return &models.MessageInfo{ + Envelope: env, + Flags: flags, + Labels: labels, + InternalDate: recDate, + Refs: parse.MsgIDList(h, "references"), + Size: 0, + Uid: raw.UID(), + Error: parseErr, + }, nil +} + +// NewCRLFReader returns a reader with CRLF line endings +func NewCRLFReader(r io.Reader) io.Reader { + var buf bytes.Buffer + scanner := bufio.NewScanner(r) + for scanner.Scan() { + buf.WriteString(scanner.Text() + "\r\n") + } + return &buf +} + +// ReadMessage is a wrapper for the message.Read function to read a message +// from r. The message's encoding and charset are automatically decoded to +// UTF-8. If an unknown charset or unknown encoding is encountered, the error is +// logged but a nil error is returned since the entity object can still be read. +func ReadMessage(r io.Reader) (*message.Entity, error) { + entity, err := message.Read(r) + switch { + case message.IsUnknownCharset(err): + // message body is valid, just not converted, so continue + log.Warnf("ReadMessage: %v", err) + case message.IsUnknownEncoding(err): + // message body is valid, just not decoded, so continue + log.Warnf("ReadMessage: %v", err) + case err != nil: + return nil, fmt.Errorf("could not read message: %w", err) + } + return entity, nil +} diff --git a/lib/rfc822/message_test.go b/lib/rfc822/message_test.go new file mode 100644 index 0000000..a5812f8 --- /dev/null +++ b/lib/rfc822/message_test.go @@ -0,0 +1,190 @@ +package rfc822 + +import ( + "io" + "os" + "path/filepath" + "testing" + "time" + + "git.sr.ht/~rjarry/aerc/models" + "github.com/emersion/go-message/mail" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMessageInfoParser(t *testing.T) { + rootDir := "testdata/message/valid" + msgFiles, err := os.ReadDir(rootDir) + die(err) + + for _, fi := range msgFiles { + if fi.IsDir() { + continue + } + + p := fi.Name() + t.Run(p, func(t *testing.T) { + m := newMockRawMessageFromPath(filepath.Join(rootDir, p)) + mi, err := MessageInfo(m) + if err != nil { + t.Fatal("Failed to create MessageInfo with:", err) + } + + if perr := mi.Error; perr != nil { + t.Fatal("Expected no parsing error, but got:", mi.Error) + } + }) + } +} + +func TestMessageInfoMalformed(t *testing.T) { + rootDir := "testdata/message/malformed" + msgFiles, err := os.ReadDir(rootDir) + die(err) + + for _, fi := range msgFiles { + if fi.IsDir() { + continue + } + + p := fi.Name() + t.Run(p, func(t *testing.T) { + m := newMockRawMessageFromPath(filepath.Join(rootDir, p)) + _, err := MessageInfo(m) + if err != nil { + t.Fatal(err) + } + }) + } +} + +func TestParseMessageDate(t *testing.T) { + // we use different times for "Date" and "Received" fields so we can check which one is parsed + // however, we accept both if the date header can be parsed using the current locale + tests := []struct { + date string + received string + utc []time.Time + }{ + { + date: "Fri, 22 Dec 2023 11:19:01 +0000", + received: "from aaa.bbb.com for <user@host.com>; Fri, 22 Dec 2023 06:19:02 -0500 (EST)", + utc: []time.Time{ + time.Date(2023, time.December, 22, 11, 19, 1, 0, time.UTC), // we expect the Date field to be parsed straight away + }, + }, + { + date: "Fri, 29 Dec 2023 14:06:37 +0100", + received: "from somewhere.com for a@b.c; Fri, 30 Dec 2023 4:06:43 +1300", + utc: []time.Time{ + time.Date(2023, time.December, 29, 13, 6, 37, 0, time.UTC), // we expect the Date field to be parsed here + }, + }, + { + date: "Fri, 29 Dec 2023 00:51:00 EST", + received: "by hostname.com; Fri, 29 Dec 2023 00:51:33 -0500 (EST)", + utc: []time.Time{ + time.Date(2023, time.December, 29, 5, 51, 33, 0, time.UTC), // in most cases the Received field will be parsed + time.Date(2023, time.December, 29, 5, 51, 0o0, 0, time.UTC), // however, if the EST locale is loaded, the Date header can be parsed too + }, + }, + } + + for _, test := range tests { + h := mail.Header{} + h.SetText("Date", test.date) + h.SetText("Received", test.received) + res, err := parseDate(&h) + require.Nil(t, err) + found := false + for _, ref := range test.utc { + if ref.Equal(res.UTC()) { + found = true + break + } + } + require.True(t, found, "Can't properly parse date and time from the Date/Received headers") + } +} + +func TestParseAddressList(t *testing.T) { + header := mail.HeaderFromMap(map[string][]string{ + "From": {`"=?utf-8?B?U21pZXRhbnNraSwgV29qY2llY2ggVGFkZXVzeiBpbiBUZWFtcw==?=" <noreply@email.teams.microsoft.com>`}, + "To": {`=?UTF-8?q?Oc=C3=A9ane_de_Seazon?= <hello@seazon.fr>`}, + "Cc": {`=?utf-8?b?0KjQsNCz0L7QsiDQk9C10L7RgNCz0LjQuSB2aWEgZGlzY3Vzcw==?= <ovs-discuss@openvswitch.org>`}, + "Bcc": {`"Foo, Baz Bar" <~foo/baz@bar.org>`}, + "Reply-To": {`Someone`}, + }) + type vector struct { + kind string + header string + name string + email string + } + + vectors := []vector{ + { + kind: "quoted", + header: "Bcc", + name: "Foo, Baz Bar", + email: "~foo/baz@bar.org", + }, + { + kind: "Qencoded", + header: "To", + name: "Océane de Seazon", + email: "hello@seazon.fr", + }, + { + kind: "Bencoded", + header: "Cc", + name: "Шагов Георгий via discuss", + email: "ovs-discuss@openvswitch.org", + }, + { + kind: "quoted+Bencoded", + header: "From", + name: "Smietanski, Wojciech Tadeusz in Teams", + email: "noreply@email.teams.microsoft.com", + }, + { + kind: "no email", + header: "Reply-To", + name: "Someone", + email: "", + }, + } + + for _, vec := range vectors { + t.Run(vec.kind, func(t *testing.T) { + addrs := parseAddressList(&header, vec.header) + assert.Len(t, addrs, 1) + assert.Equal(t, vec.name, addrs[0].Name) + assert.Equal(t, vec.email, addrs[0].Address) + }) + } +} + +type mockRawMessage struct { + path string +} + +func newMockRawMessageFromPath(p string) *mockRawMessage { + return &mockRawMessage{ + path: p, + } +} + +func (m *mockRawMessage) NewReader() (io.ReadCloser, error) { + return os.Open(m.path) +} +func (m *mockRawMessage) ModelFlags() (models.Flags, error) { return 0, nil } +func (m *mockRawMessage) Labels() ([]string, error) { return nil, nil } +func (m *mockRawMessage) UID() models.UID { return "" } + +func die(err error) { + if err != nil { + panic(err) + } +} diff --git a/lib/rfc822/testdata/message/malformed/hexa b/lib/rfc822/testdata/message/malformed/hexa new file mode 100644 index 0000000..56b352f --- /dev/null +++ b/lib/rfc822/testdata/message/malformed/hexa @@ -0,0 +1,26 @@ +Subject: Confirmation Needed gUdVJQBhsd +Content-Type: multipart/mixed; boundary="Nextpart_1Q2YJhd197991794467076Pgfa" +To: <BORK@example.com> +From: ""REGISTRAR"" <zdglopi-1Q2YJhd-noReply@example.com> + +--Nextpart_1Q2YJhd197991794467076Pgfa +Content-Type: multipart/parallel; boundary="sg54sd54g54sdg54" + +--sg54sd54g54sdg54 +Content-Type: multipart/alternative; boundary="54qgf54q546f46qsf46qsf" + +--54qgf54q546f46qsf46qsf +Content-Type: text/plain; charset=utf-8 +Content-Transfer-Encoding: Hexa + + + +--54qgf54q546f46qsf46qsf +Content-Type: text/html; charset=utf-8 + + +<CeNteR><a hRef="https://example.com-ap-southeast-example.com.com/example.com#qs=r-acacaeehdiebadgdhgghcaegckhabababaggacihaccajfbacccgaehhbkacb"><b><h2>Congratulations Netflix Customer!</h2></b></a><br> +<HeaD> +<ObJECT> + +--Nextpart_1Q2YJhd197991794467076Pgfa-- diff --git a/lib/rfc822/testdata/message/valid/quoted-mime-type b/lib/rfc822/testdata/message/valid/quoted-mime-type new file mode 100644 index 0000000..d9af28a --- /dev/null +++ b/lib/rfc822/testdata/message/valid/quoted-mime-type @@ -0,0 +1,45 @@ +Subject: Your ECOLINES tickets +X-PHP-Originating-Script: 33:functions.inc.php +From: ECOLINES <ecolines@ecolines.lv> +Content-Type: multipart/mixed; + boundary="PHP-mixed-ba319678ca12656cfb8cd46e736ce09d" +Message-Id: <E1nvIQS-0004tm-Bc@legacy.ecolines.net> +Date: Sun, 29 May 2022 15:53:44 +0300 + +--PHP-mixed-ba319678ca12656cfb8cd46e736ce09d +Content-Type: multipart/alternative; boundary="PHP-alt-ba319678ca12656cfb8cd46e736ce09d" + +--PHP-alt-ba319678ca12656cfb8cd46e736ce09d +Content-Type: text/plain; charset="UTF-8" +Content-Transfer-Encoding: 7bit + +Your tickets are attached to this message. Also You can print out Your tickets from our website www.ecolines.net<b +r /> +… + +--PHP-alt-ba319678ca12656cfb8cd46e736ce09d +Content-Type: text/html; charset="UTF-8" +Content-Transfer-Encoding: 7bit + +<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> +… + +--PHP-alt-ba319678ca12656cfb8cd46e736ce09d-- + +--PHP-mixed-ba319678ca12656cfb8cd46e736ce09d +Content-Type: "application/pdf"; name="17634428.pdf" +Content-Disposition: attachment; filename="17634428.pdf" +Content-Transfer-Encoding: base64 + +JVBERi0xLjQKMSAwIG9iago8PAovVGl0bGUgKP7/AFkAbwB1AHIAIAB0AGkAYwBrAGUAdCkKL0Ny +… + +--PHP-mixed-ba319678ca12656cfb8cd46e736ce09d +Content-Type: "application/pdf"; name="invoice-6385490.pdf" +Content-Disposition: attachment; filename="invoice-6385490.pdf" +Content-Transfer-Encoding: base64 + +JVBERi0xLjQKMSAwIG9iago8PAovVGl0bGUgKP7/AEkAbgB2AG8AaQBjAGUpCi9DcmVhdG9yICj+ +… + +--PHP-mixed-ba319678ca12656cfb8cd46e736ce09d-- diff --git a/lib/send/jmap.go b/lib/send/jmap.go new file mode 100644 index 0000000..0f6fea1 --- /dev/null +++ b/lib/send/jmap.go @@ -0,0 +1,41 @@ +package send + +import ( + "fmt" + "io" + + "github.com/emersion/go-message/mail" + + "git.sr.ht/~rjarry/aerc/worker/types" +) + +func newJmapSender( + worker *types.Worker, from *mail.Address, rcpts []*mail.Address, + copyTo []string, +) (io.WriteCloser, error) { + var writer io.WriteCloser + done := make(chan error) + + worker.PostAction( + &types.StartSendingMessage{From: from, Rcpts: rcpts, CopyTo: copyTo}, + func(msg types.WorkerMessage) { + switch msg := msg.(type) { + case *types.Done: + return + case *types.Unsupported: + done <- fmt.Errorf("unsupported by worker") + case *types.Error: + done <- msg.Error + case *types.MessageWriter: + writer = msg.Writer + default: + done <- fmt.Errorf("unexpected worker message: %#v", msg) + } + close(done) + }, + ) + + err := <-done + + return writer, err +} diff --git a/lib/send/parse.go b/lib/send/parse.go new file mode 100644 index 0000000..d1088ee --- /dev/null +++ b/lib/send/parse.go @@ -0,0 +1,52 @@ +package send + +import ( + "fmt" + "math/rand" + "net/url" + "os" + "strconv" + "strings" + + "github.com/emersion/go-message/mail" +) + +func parseScheme(uri *url.URL) (protocol string, auth string, err error) { + protocol = "" + auth = "plain" + if uri.Scheme != "" { + parts := strings.Split(uri.Scheme, "+") + switch len(parts) { + case 1: + protocol = parts[0] + case 2: + if parts[1] == "insecure" { + protocol = uri.Scheme + } else { + protocol = parts[0] + auth = parts[1] + } + case 3: + protocol = parts[0] + "+" + parts[1] + auth = parts[2] + default: + return "", "", fmt.Errorf("Unknown scheme %s", uri.Scheme) + } + } + return protocol, auth, nil +} + +func GetMessageIdHostname(sendWithHostname bool, from *mail.Address) (string, error) { + if sendWithHostname { + return os.Hostname() + } + if from == nil { + // no from address present, generate a random hostname + return strings.ToUpper(strconv.FormatInt(rand.Int63(), 36)), nil + } + _, domain, found := strings.Cut(from.Address, "@") + if !found { + return "", fmt.Errorf("Invalid address %q", from) + } + return domain, nil +} diff --git a/lib/send/sasl.go b/lib/send/sasl.go new file mode 100644 index 0000000..01e006e --- /dev/null +++ b/lib/send/sasl.go @@ -0,0 +1,77 @@ +package send + +import ( + "fmt" + "net/url" + + "github.com/emersion/go-sasl" + "golang.org/x/oauth2" + + "git.sr.ht/~rjarry/aerc/lib" +) + +func newSaslClient(auth string, uri *url.URL) (sasl.Client, error) { + var saslClient sasl.Client + switch auth { + case "": + fallthrough + case "none": + saslClient = nil + case "login": + password, _ := uri.User.Password() + saslClient = sasl.NewLoginClient(uri.User.Username(), password) + case "plain": + password, _ := uri.User.Password() + saslClient = sasl.NewPlainClient("", uri.User.Username(), password) + case "oauthbearer": + q := uri.Query() + oauth2 := &oauth2.Config{} + if q.Get("token_endpoint") != "" { + oauth2.ClientID = q.Get("client_id") + oauth2.ClientSecret = q.Get("client_secret") + oauth2.Scopes = []string{q.Get("scope")} + oauth2.Endpoint.TokenURL = q.Get("token_endpoint") + } + password, _ := uri.User.Password() + bearer := lib.OAuthBearer{ + OAuth2: oauth2, + Enabled: true, + } + if bearer.OAuth2.Endpoint.TokenURL != "" { + token, err := bearer.ExchangeRefreshToken(password) + if err != nil { + return nil, err + } + password = token.AccessToken + } + saslClient = sasl.NewOAuthBearerClient(&sasl.OAuthBearerOptions{ + Username: uri.User.Username(), + Token: password, + }) + case "xoauth2": + q := uri.Query() + oauth2 := &oauth2.Config{} + if q.Get("token_endpoint") != "" { + oauth2.ClientID = q.Get("client_id") + oauth2.ClientSecret = q.Get("client_secret") + oauth2.Scopes = []string{q.Get("scope")} + oauth2.Endpoint.TokenURL = q.Get("token_endpoint") + } + password, _ := uri.User.Password() + bearer := lib.Xoauth2{ + OAuth2: oauth2, + Enabled: true, + } + if bearer.OAuth2.Endpoint.TokenURL != "" { + token, err := bearer.ExchangeRefreshToken(password) + if err != nil { + return nil, err + } + password = token.AccessToken + } + saslClient = lib.NewXoauth2Client(uri.User.Username(), password) + default: + return nil, fmt.Errorf("Unsupported auth mechanism %s", auth) + } + return saslClient, nil +} diff --git a/lib/send/sender.go b/lib/send/sender.go new file mode 100644 index 0000000..34d00ed --- /dev/null +++ b/lib/send/sender.go @@ -0,0 +1,69 @@ +package send + +import ( + "bufio" + "bytes" + "fmt" + "io" + "net/url" + + "github.com/emersion/go-message/mail" + + "git.sr.ht/~rjarry/aerc/worker/types" +) + +// NewSender returns an io.WriterCloser into which the caller can write +// contents of a message. The caller must invoke the Close() method on the +// sender when finished. +func NewSender( + worker *types.Worker, uri *url.URL, domain string, + from *mail.Address, rcpts []*mail.Address, + copyTo []string, +) (io.WriteCloser, error) { + protocol, auth, err := parseScheme(uri) + if err != nil { + return nil, err + } + + var w io.WriteCloser + + switch protocol { + case "smtp", "smtp+insecure", "smtps": + w, err = newSmtpSender(protocol, auth, uri, domain, from, rcpts) + case "jmap": + w, err = newJmapSender(worker, from, rcpts, copyTo) + case "": + w, err = newSendmailSender(uri, rcpts) + default: + err = fmt.Errorf("unsupported protocol %s", protocol) + } + if err != nil { + return nil, err + } + return &crlfWriter{w: w}, nil +} + +type crlfWriter struct { + w io.WriteCloser + buf bytes.Buffer +} + +func (w *crlfWriter) Write(p []byte) (int, error) { + return w.buf.Write(p) +} + +func (w *crlfWriter) Close() error { + defer w.w.Close() // ensure closed even on error + + scan := bufio.NewScanner(&w.buf) + for scan.Scan() { + if _, err := w.w.Write(append(scan.Bytes(), '\r', '\n')); err != nil { + return nil + } + } + if scan.Err() != nil { + return scan.Err() + } + + return w.w.Close() +} diff --git a/lib/send/sendmail.go b/lib/send/sendmail.go new file mode 100644 index 0000000..9d98cf8 --- /dev/null +++ b/lib/send/sendmail.go @@ -0,0 +1,55 @@ +package send + +import ( + "fmt" + "io" + "net/url" + "os/exec" + + "git.sr.ht/~rjarry/go-opt/v2" + "github.com/emersion/go-message/mail" + "github.com/pkg/errors" +) + +type sendmailSender struct { + cmd *exec.Cmd + stdin io.WriteCloser +} + +func (s *sendmailSender) Write(p []byte) (int, error) { + return s.stdin.Write(p) +} + +func (s *sendmailSender) Close() error { + se := s.stdin.Close() + ce := s.cmd.Wait() + if se != nil { + return se + } + return ce +} + +func newSendmailSender(uri *url.URL, rcpts []*mail.Address) (io.WriteCloser, error) { + args := opt.SplitArgs(uri.Path) + if len(args) == 0 { + return nil, fmt.Errorf("no command specified") + } + bin := args[0] + rs := make([]string, len(rcpts)) + for i := range rcpts { + rs[i] = rcpts[i].Address + } + args = append(args[1:], rs...) + cmd := exec.Command(bin, args...) + s := &sendmailSender{cmd: cmd} + var err error + s.stdin, err = s.cmd.StdinPipe() + if err != nil { + return nil, errors.Wrap(err, "cmd.StdinPipe") + } + err = s.cmd.Start() + if err != nil { + return nil, errors.Wrap(err, "cmd.Start") + } + return s, nil +} diff --git a/lib/send/smtp.go b/lib/send/smtp.go new file mode 100644 index 0000000..77406fb --- /dev/null +++ b/lib/send/smtp.go @@ -0,0 +1,134 @@ +package send + +import ( + "crypto/tls" + "fmt" + "io" + "net/url" + "strings" + + "github.com/emersion/go-message/mail" + "github.com/emersion/go-smtp" + "github.com/pkg/errors" +) + +func connectSmtp(starttls bool, host string, domain string) (*smtp.Client, error) { + serverName := host + if !strings.ContainsRune(host, ':') { + host += ":587" // Default to submission port + } else { + serverName = host[:strings.IndexRune(host, ':')] + } + var conn *smtp.Client + var err error + if starttls { + conn, err = smtp.DialStartTLS(host, &tls.Config{ServerName: serverName}) + } else { + conn, err = smtp.Dial(host) + } + if err != nil { + return nil, errors.Wrap(err, "smtp.Dial") + } + if domain != "" { + err := conn.Hello(domain) + if err != nil { + conn.Close() + return nil, errors.Wrap(err, "Hello") + } + } + return conn, nil +} + +func connectSmtps(host string, domain string) (*smtp.Client, error) { + serverName := host + if !strings.ContainsRune(host, ':') { + host += ":465" // Default to smtps port + } else { + serverName = host[:strings.IndexRune(host, ':')] + } + conn, err := smtp.DialTLS(host, &tls.Config{ + ServerName: serverName, + }) + if err != nil { + return nil, errors.Wrap(err, "smtp.DialTLS") + } + if domain != "" { + err := conn.Hello(domain) + if err != nil { + conn.Close() + return nil, errors.Wrap(err, "Hello") + } + } + return conn, nil +} + +type smtpSender struct { + conn *smtp.Client + w io.WriteCloser +} + +func (s *smtpSender) Write(p []byte) (int, error) { + return s.w.Write(p) +} + +func (s *smtpSender) Close() error { + we := s.w.Close() + ce := s.conn.Close() + if we != nil { + return we + } + return ce +} + +func newSmtpSender( + protocol string, auth string, uri *url.URL, domain string, + from *mail.Address, rcpts []*mail.Address, +) (io.WriteCloser, error) { + var err error + var conn *smtp.Client + switch protocol { + case "smtp": + conn, err = connectSmtp(true, uri.Host, domain) + case "smtp+insecure": + conn, err = connectSmtp(false, uri.Host, domain) + case "smtps": + conn, err = connectSmtps(uri.Host, domain) + default: + return nil, fmt.Errorf("not a smtp protocol %s", protocol) + } + + if err != nil { + return nil, errors.Wrap(err, "Connection failed") + } + + saslclient, err := newSaslClient(auth, uri) + if err != nil { + conn.Close() + return nil, err + } + if saslclient != nil { + if err := conn.Auth(saslclient); err != nil { + conn.Close() + return nil, errors.Wrap(err, "conn.Auth") + } + } + s := &smtpSender{ + conn: conn, + } + if err := s.conn.Mail(from.Address, nil); err != nil { + conn.Close() + return nil, errors.Wrap(err, "conn.Mail") + } + for _, rcpt := range rcpts { + if err := s.conn.Rcpt(rcpt.Address, nil); err != nil { + conn.Close() + return nil, errors.Wrap(err, "conn.Rcpt") + } + } + s.w, err = s.conn.Data() + if err != nil { + conn.Close() + return nil, errors.Wrap(err, "conn.Data") + } + return s.w, nil +} diff --git a/lib/sort/sort.go b/lib/sort/sort.go new file mode 100644 index 0000000..b62b8d0 --- /dev/null +++ b/lib/sort/sort.go @@ -0,0 +1,87 @@ +package sort + +import ( + "errors" + "fmt" + "sort" + "strings" + + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/types" +) + +func GetSortCriteria(args []string) ([]*types.SortCriterion, error) { + var sortCriteria []*types.SortCriterion + reverse := false + for _, arg := range args { + if arg == "-r" { + reverse = true + continue + } + field, err := parseSortField(arg) + if err != nil { + return nil, err + } + sortCriteria = append(sortCriteria, &types.SortCriterion{ + Field: field, + Reverse: reverse, + }) + reverse = false + } + if reverse { + return nil, errors.New("Expected argument to reverse") + } + return sortCriteria, nil +} + +func parseSortField(arg string) (types.SortField, error) { + switch strings.ToLower(arg) { + case "arrival": + return types.SortArrival, nil + case "cc": + return types.SortCc, nil + case "date": + return types.SortDate, nil + case "from": + return types.SortFrom, nil + case "read": + return types.SortRead, nil + case "size": + return types.SortSize, nil + case "subject": + return types.SortSubject, nil + case "to": + return types.SortTo, nil + case "flagged": + return types.SortFlagged, nil + default: + return types.SortArrival, fmt.Errorf("%v is not a valid sort criterion", arg) + } +} + +// Sorts toSort by sortBy so that toSort becomes a permutation following the +// order of sortBy. +// toSort should be a subset of sortBy +func SortBy(toSort []models.UID, sortBy []models.UID) { + // build a map from sortBy + uidMap := make(map[models.UID]int) + for i, uid := range sortBy { + uidMap[uid] = i + } + // sortslice of toSort with less function of indexing the map sortBy + sort.Slice(toSort, func(i, j int) bool { + return uidMap[toSort[i]] < uidMap[toSort[j]] + }) +} + +// SortStringBy sorts the string slice s according to the order given in the +// order string slice. +func SortStringBy(s []string, order []string) { + m := make(map[string]int) + for i, d := range order { + m[d] = i + } + sort.Slice(s, func(i, j int) bool { + return m[s[i]] < m[s[j]] + }) +} diff --git a/lib/state/state.go b/lib/state/state.go new file mode 100644 index 0000000..4943102 --- /dev/null +++ b/lib/state/state.go @@ -0,0 +1,97 @@ +package state + +import ( + "fmt" +) + +type AccountState struct { + Connected bool + connActivity string + passthrough bool + folders map[string]*folderState +} + +type folderState struct { + Search string + Filter string + FilterActivity string + Sorting bool + Threading bool +} + +func (s *AccountState) folderState(folder string) *folderState { + if s.folders == nil { + s.folders = make(map[string]*folderState) + } + if _, ok := s.folders[folder]; !ok { + s.folders[folder] = &folderState{} + } + return s.folders[folder] +} + +type SetStateFunc func(s *AccountState, folder string) + +func SetConnected(state bool) SetStateFunc { + return func(s *AccountState, folder string) { + s.connActivity = "" + s.Connected = state + } +} + +func ConnectionActivity(desc string) SetStateFunc { + return func(s *AccountState, folder string) { + s.connActivity = desc + } +} + +func SearchFilterClear() SetStateFunc { + return func(s *AccountState, folder string) { + s.folderState(folder).Search = "" + s.folderState(folder).FilterActivity = "" + s.folderState(folder).Filter = "" + } +} + +func FilterActivity(str string) SetStateFunc { + return func(s *AccountState, folder string) { + s.folderState(folder).FilterActivity = str + } +} + +func FilterResult(str string) SetStateFunc { + return func(s *AccountState, folder string) { + s.folderState(folder).FilterActivity = "" + s.folderState(folder).Filter = concatFilters(s.folderState(folder).Filter, str) + } +} + +func concatFilters(existing, next string) string { + if existing == "" { + return next + } + return fmt.Sprintf("%s && %s", existing, next) +} + +func Search(desc string) SetStateFunc { + return func(s *AccountState, folder string) { + s.folderState(folder).Search = desc + } +} + +func Sorting(on bool) SetStateFunc { + return func(s *AccountState, folder string) { + s.folderState(folder).Sorting = on + } +} + +func Threading(on bool) SetStateFunc { + return func(s *AccountState, folder string) { + s.folderState(folder).Threading = on + } +} + +func Passthrough(on bool) SetStateFunc { + return func(s *AccountState, folder string) { + s.passthrough = on + } +} diff --git a/lib/state/templates.go b/lib/state/templates.go new file mode 100644 index 0000000..c208dff --- /dev/null +++ b/lib/state/templates.go @@ -0,0 +1,769 @@ +package state + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "os" + "os/exec" + "strings" + "time" + + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/ui" + "git.sr.ht/~rjarry/aerc/lib/xdg" + "git.sr.ht/~rjarry/aerc/models" + "github.com/danwakefield/fnmatch" + sortthread "github.com/emersion/go-imap-sortthread" + "github.com/emersion/go-message/mail" +) + +type Composer interface { + AddAttachment(string) +} + +type DataSetter interface { + Data() models.TemplateData + SetHeaders(*mail.Header, *models.OriginalMail) + SetInfo(*models.MessageInfo, int, bool) + SetVisual(bool) + SetThreading(ThreadInfo) + SetComposer(Composer) + SetAccount(*config.AccountConfig) + SetFolder(*models.Directory) + SetRUE([]string, func(string) (int, int, int)) + SetState(s *AccountState) + SetPendingKeys([]config.KeyStroke) +} + +type ThreadInfo struct { + SameSubject bool + Prefix string + Count int + Unread int + Folded bool + Context bool + Orphan bool +} + +type templateData struct { + // only available when composing/replying/forwarding + headers *mail.Header + // only available when replying with a quote + parent *models.OriginalMail + // only available for the message list + info *models.MessageInfo + marked bool + msgNum int + visual bool + + // message list threading + threadInfo ThreadInfo + + // selected account + account *config.AccountConfig + myAddresses map[string]bool + folder *models.Directory // selected folder + folders []string + getRUEcount func(string) (int, int, int) + + state *AccountState + pendingKeys []config.KeyStroke + + composer Composer +} + +func NewDataSetter() DataSetter { + return &templateData{} +} + +// Data returns the template data +func (d *templateData) Data() models.TemplateData { + return d +} + +// only used for compose/reply/forward +func (d *templateData) SetHeaders(h *mail.Header, o *models.OriginalMail) { + d.headers = h + d.parent = o +} + +// only used for message list templates +func (d *templateData) SetInfo(info *models.MessageInfo, num int, marked bool, +) { + d.info = info + d.msgNum = num + d.marked = marked +} + +func (d *templateData) SetVisual(visual bool) { + d.visual = visual +} + +func (d *templateData) SetThreading(info ThreadInfo) { + d.threadInfo = info +} + +func (d *templateData) SetAccount(acct *config.AccountConfig) { + d.account = acct + d.myAddresses = make(map[string]bool) + if acct != nil { + d.myAddresses[acct.From.Address] = true + for _, addr := range acct.Aliases { + d.myAddresses[addr.Address] = true + } + } +} + +func (d *templateData) SetFolder(folder *models.Directory) { + d.folder = folder +} + +func (d *templateData) SetComposer(c Composer) { + d.composer = c +} + +func (d *templateData) SetRUE(folders []string, + cb func(string) (int, int, int), +) { + d.folders = folders + d.getRUEcount = cb +} + +func (d *templateData) SetState(state *AccountState) { + d.state = state +} + +func (d *templateData) SetPendingKeys(keys []config.KeyStroke) { + d.pendingKeys = keys +} + +func (d *templateData) Attach(s string) string { + if d.composer != nil { + d.composer.AddAttachment(s) + return "" + } + return fmt.Sprintf("Failed to attach: %s", s) +} + +func (d *templateData) Account() string { + if d.account != nil { + return d.account.Name + } + return "" +} + +func (d *templateData) AccountBackend() string { + if d.account != nil { + return d.account.Backend + } + return "" +} + +func (d *templateData) AccountFrom() *mail.Address { + if d.account != nil { + return d.account.From + } + return nil +} + +func (d *templateData) Folder() string { + if d.folder != nil { + return d.folder.Name + } + return "" +} + +func (d *templateData) Role() string { + if d.folder != nil { + return string(d.folder.Role) + } + return "" +} + +func (d *templateData) ui() *config.UIConfig { + return config.Ui.ForAccount(d.Account()).ForFolder(d.Folder()) +} + +func (d *templateData) To() []*mail.Address { + var to []*mail.Address + switch { + case d.info != nil && d.info.Envelope != nil: + to = d.info.Envelope.To + case d.headers != nil: + to, _ = d.headers.AddressList("to") + } + return to +} + +func (d *templateData) Cc() []*mail.Address { + var cc []*mail.Address + switch { + case d.info != nil && d.info.Envelope != nil: + cc = d.info.Envelope.Cc + case d.headers != nil: + cc, _ = d.headers.AddressList("cc") + } + return cc +} + +func (d *templateData) Bcc() []*mail.Address { + var bcc []*mail.Address + switch { + case d.info != nil && d.info.Envelope != nil: + bcc = d.info.Envelope.Bcc + case d.headers != nil: + bcc, _ = d.headers.AddressList("bcc") + } + return bcc +} + +func (d *templateData) From() []*mail.Address { + var from []*mail.Address + switch { + case d.info != nil && d.info.Envelope != nil: + from = d.info.Envelope.From + case d.headers != nil: + from, _ = d.headers.AddressList("from") + } + return from +} + +func (d *templateData) Peer() []*mail.Address { + var from, to []*mail.Address + switch { + case d.info != nil && d.info.Envelope != nil: + from = d.info.Envelope.From + to = d.info.Envelope.To + case d.headers != nil: + from, _ = d.headers.AddressList("from") + to, _ = d.headers.AddressList("to") + } + for _, addr := range from { + for myAddr := range d.myAddresses { + if fnmatch.Match(myAddr, addr.Address, 0) { + return to + } + } + } + return from +} + +func (d *templateData) ReplyTo() []*mail.Address { + var replyTo []*mail.Address + switch { + case d.info != nil && d.info.Envelope != nil: + replyTo = d.info.Envelope.ReplyTo + case d.headers != nil: + replyTo, _ = d.headers.AddressList("reply-to") + } + return replyTo +} + +func (d *templateData) Date() time.Time { + var date time.Time + switch { + case d.info != nil && d.info.Envelope != nil: + date = d.info.Envelope.Date + case d.info != nil: + date = d.info.InternalDate + default: + date = time.Now() + } + return date +} + +func (d *templateData) DateAutoFormat(date time.Time) string { + if date.IsZero() { + return "" + } + ui := d.ui() + year := date.Year() + day := date.YearDay() + now := time.Now() + thisYear := now.Year() + thisDay := now.YearDay() + fmt := ui.TimestampFormat + if year == thisYear { + switch { + case day == thisDay && ui.ThisDayTimeFormat != "": + fmt = ui.ThisDayTimeFormat + case day > thisDay-7 && ui.ThisWeekTimeFormat != "": + fmt = ui.ThisWeekTimeFormat + case ui.ThisYearTimeFormat != "": + fmt = ui.ThisYearTimeFormat + } + } + return date.Format(fmt) +} + +func (d *templateData) Header(name string) string { + var h *mail.Header + switch { + case d.headers != nil: + h = d.headers + case d.info != nil && d.info.RFC822Headers != nil: + h = d.info.RFC822Headers + default: + return "" + } + text, err := h.Text(name) + if err != nil { + text = h.Get(name) + } + return text +} + +func (d *templateData) ThreadPrefix() string { + return d.threadInfo.Prefix +} + +func (d *templateData) ThreadCount() int { + return d.threadInfo.Count +} + +func (d *templateData) ThreadUnread() int { + return d.threadInfo.Unread +} + +func (d *templateData) ThreadFolded() bool { + return d.threadInfo.Folded +} + +func (d *templateData) ThreadContext() bool { + return d.threadInfo.Context +} + +func (d *templateData) ThreadOrphan() bool { + return d.threadInfo.Orphan +} + +func (d *templateData) Subject() string { + var subject string + switch { + case d.info != nil && d.info.Envelope != nil: + subject = d.info.Envelope.Subject + case d.headers != nil: + subject = d.Header("subject") + } + if d.threadInfo.SameSubject { + subject = "" + } else if subject == "" { + subject = config.Ui.EmptySubject + } + return subject +} + +func (d *templateData) SubjectBase() string { + var subject string + switch { + case d.info != nil && d.info.Envelope != nil: + subject = d.info.Envelope.Subject + case d.headers != nil: + subject = d.Header("subject") + } + base, _ := sortthread.GetBaseSubject(subject) + return base +} + +func (d *templateData) Number() int { + return d.msgNum +} + +func (d *templateData) Labels() []string { + if d.info == nil { + return nil + } + return d.info.Labels +} + +func (d *templateData) Filename() string { + if d.info == nil { + return "" + } + if (d.info.Filenames != nil) && len(d.info.Filenames) > 0 { + return d.info.Filenames[0] + } + return "" +} + +func (d *templateData) Filenames() []string { + if d.info == nil { + return nil + } + return d.info.Filenames +} + +func (d *templateData) Flags() []string { + var flags []string + if d.info == nil { + return flags + } + + switch { + case d.info.Flags.Has(models.SeenFlag | models.AnsweredFlag): + flags = append(flags, d.ui().IconReplied) // message has been replied to + case d.info.Flags.Has(models.SeenFlag): + break + case d.info.Flags.Has(models.RecentFlag): + flags = append(flags, d.ui().IconNew) // message is unread and new + default: + flags = append(flags, d.ui().IconOld) // message is unread and old + } + if d.info.Flags.Has(models.DraftFlag) { + flags = append(flags, d.ui().IconDraft) + } + if d.info.Flags.Has(models.DeletedFlag) { + flags = append(flags, d.ui().IconDeleted) + } + if d.info.Flags.Has(models.ForwardedFlag) { + flags = append(flags, d.ui().IconForwarded) + } + if d.info.BodyStructure != nil { + for _, bS := range d.info.BodyStructure.Parts { + if strings.ToLower(bS.Disposition) == "attachment" { + flags = append(flags, d.ui().IconAttachment) + break + } + } + } + if d.info.Flags.Has(models.FlaggedFlag) { + flags = append(flags, d.ui().IconFlagged) + } + if d.marked { + flags = append(flags, d.ui().IconMarked) + } + return flags +} + +func (d *templateData) IsReplied() bool { + if d.info != nil && d.info.Flags.Has(models.AnsweredFlag) { + return true + } + return false +} + +func (d *templateData) IsForwarded() bool { + if d.info != nil && d.info.Flags.Has(models.ForwardedFlag) { + return true + } + return false +} + +func (d *templateData) HasAttachment() bool { + if d.info != nil && d.info.BodyStructure != nil { + for _, bS := range d.info.BodyStructure.Parts { + if strings.ToLower(bS.Disposition) == "attachment" { + return true + } + } + } + return false +} + +func (d *templateData) IsRecent() bool { + if d.info != nil && d.info.Flags.Has(models.RecentFlag) { + return true + } + return false +} + +func (d *templateData) IsUnread() bool { + if d.info != nil && !d.info.Flags.Has(models.SeenFlag) { + return true + } + return false +} + +func (d *templateData) IsFlagged() bool { + if d.info != nil && d.info.Flags.Has(models.FlaggedFlag) { + return true + } + return false +} + +func (d *templateData) IsDraft() bool { + if d.info != nil && d.info.Flags.Has(models.DraftFlag) { + return true + } + return false +} + +func (d *templateData) IsMarked() bool { + return d.marked +} + +func (d *templateData) MessageId() string { + if d.info == nil || d.info.Envelope == nil { + return "" + } + return d.info.Envelope.MessageId +} + +func (d *templateData) Size() int { + if d.info == nil || d.info.Envelope == nil { + return 0 + } + return int(d.info.Size) +} + +func (d *templateData) OriginalText() string { + if d.parent == nil { + return "" + } + return d.parent.Text +} + +func (d *templateData) OriginalDate() time.Time { + if d.parent == nil { + return time.Time{} + } + return d.parent.Date +} + +func (d *templateData) OriginalFrom() []*mail.Address { + if d.parent == nil || d.parent.RFC822Headers == nil { + return nil + } + from, _ := d.parent.RFC822Headers.AddressList("from") + return from +} + +func (d *templateData) OriginalMIMEType() string { + if d.parent == nil { + return "" + } + return d.parent.MIMEType +} + +func (d *templateData) OriginalHeader(name string) string { + if d.parent == nil || d.parent.RFC822Headers == nil { + return "" + } + text, err := d.parent.RFC822Headers.Text(name) + if err != nil { + text = d.parent.RFC822Headers.Get(name) + } + return text +} + +func (d *templateData) rue(folders ...string) (int, int, int) { + var recent, unread, exists int + if d.getRUEcount != nil { + if len(folders) == 0 { + folders = d.folders + } + for _, dir := range folders { + r, u, e := d.getRUEcount(dir) + recent += r + unread += u + exists += e + } + } + return recent, unread, exists +} + +func (d *templateData) Recent(folders ...string) int { + r, _, _ := d.rue(folders...) + return r +} + +func (d *templateData) Unread(folders ...string) int { + _, u, _ := d.rue(folders...) + return u +} + +func (d *templateData) Exists(folders ...string) int { + _, _, e := d.rue(folders...) + return e +} + +func (d *templateData) RUE(folders ...string) string { + r, u, e := d.rue(folders...) + switch { + case r > 0: + return fmt.Sprintf("%d/%d/%d", r, u, e) + case u > 0: + return fmt.Sprintf("%d/%d", u, e) + case e > 0: + return fmt.Sprintf("%d", e) + } + return "" +} + +func (d *templateData) Connected() bool { + if d.state != nil { + return d.state.Connected + } + return false +} + +func (d *templateData) ConnectionInfo() string { + switch { + case d.state == nil: + return "" + case d.state.connActivity != "": + return d.state.connActivity + case d.state.Connected: + return texter().Connected() + default: + return texter().Disconnected() + } +} + +func (d *templateData) ContentInfo() string { + if d.state == nil { + return "" + } + var content []string + fldr := d.state.folderState(d.Folder()) + if fldr.FilterActivity != "" { + content = append(content, fldr.FilterActivity) + } else if fldr.Filter != "" { + content = append(content, texter().FormatFilter(fldr.Filter)) + } + if fldr.Search != "" { + content = append(content, texter().FormatSearch(fldr.Search)) + } + return strings.Join(content, config.Statusline.Separator) +} + +func (d *templateData) StatusInfo() string { + stat := d.ConnectionInfo() + if content := d.ContentInfo(); content != "" { + stat += config.Statusline.Separator + content + } + return stat +} + +func (d *templateData) TrayInfo() string { + if d.state == nil { + return "" + } + var tray []string + fldr := d.state.folderState(d.Folder()) + if fldr.Sorting { + tray = append(tray, texter().Sorting()) + } + if fldr.Threading { + tray = append(tray, texter().Threading()) + } + if d.state.passthrough { + tray = append(tray, texter().Passthrough()) + } + if d.visual { + tray = append(tray, texter().Visual()) + } + return strings.Join(tray, config.Statusline.Separator) +} + +func (d *templateData) PendingKeys() string { + return config.FormatKeyStrokes(d.pendingKeys) +} + +func (d *templateData) Style(content, name string) string { + cfg := config.Ui.ForAccount(d.Account()) + style := cfg.GetUserStyle(name) + return ui.ApplyStyle(style, content) +} + +func (d *templateData) StyleSwitch(content string, cases ...models.Case) string { + for _, c := range cases { + if c.Matches(content) { + cfg := config.Ui.ForAccount(d.Account()) + style := cfg.GetUserStyle(c.Value()) + return ui.ApplyStyle(style, content) + } + } + return content +} + +func (d *templateData) StyleMap(elems []string, cases ...models.Case) []string { + mapped := make([]string, 0, len(elems)) +top: + for _, e := range elems { + for _, c := range cases { + if c.Matches(e) { + if c.Skip() { + continue top + } + cfg := config.Ui.ForAccount(d.Account()) + style := cfg.GetUserStyle(c.Value()) + e = ui.ApplyStyle(style, e) + break + } + } + mapped = append(mapped, e) + } + return mapped +} + +func (d *templateData) Signature() string { + if d.account == nil { + return "" + } + var signature []byte + if d.account.SignatureCmd != "" { + var err error + signature, err = d.readSignatureFromCmd() + if err != nil { + var execErr *exec.ExitError + if errors.As(err, &execErr) { + log.Warnf("signature command failed with error (%d): %s", execErr.ExitCode(), execErr.Stderr) + } + signature = d.readSignatureFromFile() + } + } else { + signature = d.readSignatureFromFile() + } + if len(bytes.TrimSpace(signature)) == 0 { + return "" + } + signature = d.ensureSignatureDelimiter(signature) + return string(signature) +} + +func (d *templateData) readSignatureFromCmd() ([]byte, error) { + sigCmd := d.account.SignatureCmd + cmd := exec.Command("sh", "-c", sigCmd) + env := os.Environ() + env = append(env, fmt.Sprintf("AERC_ACCOUNT=%s", d.account.Name)) + env = append(env, fmt.Sprintf("AERC_FOLDER=%s", d.folder.Name)) + cmd.Env = env + signature, err := cmd.Output() + if err != nil { + return nil, err + } + return signature, nil +} + +func (d *templateData) readSignatureFromFile() []byte { + sigFile := d.account.SignatureFile + if sigFile == "" { + return nil + } + sigFile = xdg.ExpandHome(sigFile) + signature, err := os.ReadFile(sigFile) + if err != nil { + log.Errorf(" Error loading signature from file: %v", sigFile) + return nil + } + return signature +} + +func (d *templateData) ensureSignatureDelimiter(signature []byte) []byte { + buf := bytes.NewBuffer(signature) + scanner := bufio.NewScanner(buf) + for scanner.Scan() { + line := scanner.Text() + if line == "-- " { + // signature contains standard delimiter, we're good + return signature + } + } + // signature does not contain standard delimiter, prepend one + sig := "\n\n-- \n" + strings.TrimLeft(string(signature), " \t\r\n") + return []byte(sig) +} diff --git a/lib/state/texter.go b/lib/state/texter.go new file mode 100644 index 0000000..f51d4d3 --- /dev/null +++ b/lib/state/texter.go @@ -0,0 +1,99 @@ +package state + +import ( + "strings" + + "git.sr.ht/~rjarry/aerc/config" +) + +type texterInterface interface { + Connected() string + Disconnected() string + Passthrough() string + Sorting() string + Threading() string + Visual() string + FormatFilter(string) string + FormatSearch(string) string +} + +type text struct{} + +var txt text + +func (t text) Connected() string { + return "Connected" +} + +func (t text) Disconnected() string { + return "Disconnected" +} + +func (t text) Passthrough() string { + return "passthrough" +} + +func (t text) Sorting() string { + return "sorting" +} + +func (t text) Threading() string { + return "threading" +} + +func (t text) Visual() string { + return "visual" +} + +func (t text) FormatFilter(s string) string { + return s +} + +func (t text) FormatSearch(s string) string { + return s +} + +type icon struct{} + +var icn icon + +func (i icon) Connected() string { + return "✓" +} + +func (i icon) Disconnected() string { + return "✘" +} + +func (i icon) Passthrough() string { + return "➔" +} + +func (i icon) Sorting() string { + return "⚙" +} + +func (i icon) Threading() string { + return "🧵" +} + +func (i icon) Visual() string { + return "🕶" +} + +func (i icon) FormatFilter(s string) string { + return strings.ReplaceAll(s, "filter", "🔦") +} + +func (i icon) FormatSearch(s string) string { + return strings.ReplaceAll(s, "search", "🔎") +} + +func texter() texterInterface { + switch strings.ToLower(config.Statusline.DisplayMode) { + case "icon": + return &icn + default: + return &txt + } +} diff --git a/lib/structure_helpers.go b/lib/structure_helpers.go new file mode 100644 index 0000000..7b5865a --- /dev/null +++ b/lib/structure_helpers.go @@ -0,0 +1,76 @@ +package lib + +import ( + "strings" + + "git.sr.ht/~rjarry/aerc/models" +) + +// FindMIMEPart finds the first message part with the provided MIME type. +// FindMIMEPart recurses inside multipart containers. +func FindMIMEPart(mime string, bs *models.BodyStructure, path []int) []int { + for i, part := range bs.Parts { + cur := append(path, i+1) //nolint:gocritic // intentional append to different slice + if part.FullMIMEType() == mime { + return cur + } + if strings.ToLower(part.MIMEType) == "multipart" { + if path := FindMIMEPart(mime, part, cur); path != nil { + return path + } + } + } + return nil +} + +func FindPlaintext(bs *models.BodyStructure, path []int) []int { + return FindMIMEPart("text/plain", bs, path) +} + +func FindCalendartext(bs *models.BodyStructure, path []int) []int { + return FindMIMEPart("text/calendar", bs, path) +} + +func FindFirstNonMultipart(bs *models.BodyStructure, path []int) []int { + for i, part := range bs.Parts { + cur := append(path, i+1) //nolint:gocritic // intentional append to different slice + mimetype := strings.ToLower(part.MIMEType) + if mimetype != "multipart" { + return cur + } else if mimetype == "multipart" { + if path := FindFirstNonMultipart(part, cur); path != nil { + return path + } + } + } + return nil +} + +func FindAllNonMultipart(bs *models.BodyStructure, path []int, pathlist [][]int) [][]int { + for i, part := range bs.Parts { + cur := append(path, i+1) //nolint:gocritic // intentional append to different slice + mimetype := strings.ToLower(part.MIMEType) + if mimetype != "multipart" { + tmp := make([]int, len(cur)) + copy(tmp, cur) + pathlist = append(pathlist, tmp) + } else if mimetype == "multipart" { + if sub := FindAllNonMultipart(part, cur, nil); len(sub) > 0 { + pathlist = append(pathlist, sub...) + } + } + } + return pathlist +} + +func EqualParts(a []int, b []int) bool { + if len(a) != len(b) { + return false + } + for i := 0; i < len(a); i++ { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/lib/structure_helpers_test.go b/lib/structure_helpers_test.go new file mode 100644 index 0000000..a63825d --- /dev/null +++ b/lib/structure_helpers_test.go @@ -0,0 +1,44 @@ +package lib_test + +import ( + "testing" + + "git.sr.ht/~rjarry/aerc/lib" + "git.sr.ht/~rjarry/aerc/models" +) + +func TestLib_FindAllNonMultipart(t *testing.T) { + testStructure := &models.BodyStructure{ + MIMEType: "multipart", + Parts: []*models.BodyStructure{ + {}, + { + MIMEType: "multipart", + Parts: []*models.BodyStructure{ + {}, + {}, + }, + }, + {}, + }, + } + + expected := [][]int{ + {1}, + {2, 1}, + {2, 2}, + {3}, + } + + parts := lib.FindAllNonMultipart(testStructure, nil, nil) + + if len(expected) != len(parts) { + t.Errorf("incorrect dimensions; expected: %v, got: %v", expected, parts) + } + + for i := 0; i < len(parts); i++ { + if !lib.EqualParts(expected[i], parts[i]) { + t.Errorf("incorrect values; expected: %v, got: %v", expected[i], parts[i]) + } + } +} diff --git a/lib/templates/functions.go b/lib/templates/functions.go new file mode 100644 index 0000000..e502935 --- /dev/null +++ b/lib/templates/functions.go @@ -0,0 +1,408 @@ +package templates + +import ( + "bufio" + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "text/template" + "time" + + "git.sr.ht/~rjarry/aerc/lib/format" + "git.sr.ht/~rjarry/aerc/lib/parse" + "git.sr.ht/~rjarry/aerc/models" + "github.com/emersion/go-message/mail" +) + +var version string + +// SetVersion initializes the aerc version displayed in template functions +func SetVersion(v string) { + version = v +} + +var execPath string + +func SetExecPath(dirs []string) { + // prepend aerc filters dirs to the default exec path + paths := make([]string, 0, len(dirs)+1) + for _, d := range dirs { + paths = append(paths, filepath.Join(d, "filters")) + } + paths = append(paths, os.Getenv("PATH")) + execPath = strings.Join(paths, ":") +} + +// wrap allows to chain wrapText +func wrap(lineWidth int, text string) string { + return wrapText(text, lineWidth) +} + +func wrapLine(text string, lineWidth int) string { + words := strings.Fields(text) + if len(words) == 0 { + return text + } + var wrapped strings.Builder + wrapped.WriteString(words[0]) + spaceLeft := lineWidth - wrapped.Len() + for _, word := range words[1:] { + if len(word)+1 > spaceLeft { + wrapped.WriteRune('\n') + wrapped.WriteString(word) + spaceLeft = lineWidth - len(word) + } else { + wrapped.WriteRune(' ') + wrapped.WriteString(word) + spaceLeft -= 1 + len(word) + } + } + + return wrapped.String() +} + +func wrapText(text string, lineWidth int) string { + text = strings.ReplaceAll(text, "\r\n", "\n") + text = strings.TrimRight(text, "\n") + lines := strings.Split(text, "\n") + var wrapped strings.Builder + + for _, line := range lines { + switch { + case line == "": + // deliberately left blank + case line[0] == '>': + // leave quoted text alone + wrapped.WriteString(line) + default: + wrapped.WriteString(wrapLine(line, lineWidth)) + } + wrapped.WriteRune('\n') + } + return wrapped.String() +} + +// quote prepends "> " in front of every line in text +func quote(text string) string { + text = strings.ReplaceAll(text, "\r\n", "\n") + text = strings.TrimRight(text, "\n") + lines := strings.Split(text, "\n") + var quoted strings.Builder + for _, line := range lines { + if line == "" { + quoted.WriteString(">\n") + continue + } + if strings.HasPrefix(line, ">") { + quoted.WriteString(">") + } else { + quoted.WriteString("> ") + } + quoted.WriteString(line) + quoted.WriteRune('\n') + } + + return quoted.String() +} + +// cmd allow to parse reply by shell command +// text have to be passed by cmd param +// if there is error, original string is returned +func cmd(cmd, text string) string { + var out bytes.Buffer + c := exec.Command("sh", "-c", cmd) + c.Env = append(os.Environ(), "PATH="+execPath) + c.Stdin = strings.NewReader(text) + c.Stdout = &out + err := c.Run() + if err != nil { + return text + } + return out.String() +} + +func toLocal(t time.Time) time.Time { + return time.Time.In(t, time.Local) +} + +func rearrangeNameWithComma(name string) string { + parts := strings.SplitN(name, ",", 3) + if len(parts) == 2 { + return fmt.Sprintf("%s %s", strings.TrimSpace(parts[1]), strings.TrimSpace(parts[0])) + } + return name +} + +func names(addresses []*mail.Address) []string { + n := make([]string, len(addresses)) + for i, addr := range addresses { + name := rearrangeNameWithComma(addr.Name) + if name == "" { + parts := strings.SplitN(addr.Address, "@", 2) + name = parts[0] + } + n[i] = name + } + return n +} + +func firstnames(addresses []*mail.Address) []string { + n := make([]string, len(addresses)) + for i, addr := range addresses { + var name string + if addr.Name == "" { + parts := strings.SplitN(addr.Address, "@", 2) + parts = strings.SplitN(parts[0], ".", 2) + name = parts[0] + } else { + name = rearrangeNameWithComma(addr.Name) + name = strings.SplitN(name, " ", 2)[0] // split by spaces and get the first word + name = strings.SplitN(name, ",", 2)[0] // split by commas and get the first word + } + n[i] = name + } + return n +} + +func initials(addresses []*mail.Address) []string { + n := names(addresses) + ret := make([]string, len(addresses)) + for i, name := range n { + split := strings.Split(name, " ") + initial := "" + for _, s := range split { + initial += string([]rune(s)[0:1]) + } + ret[i] = initial + } + return ret +} + +func emails(addresses []*mail.Address) []string { + e := make([]string, len(addresses)) + for i, addr := range addresses { + e[i] = addr.Address + } + return e +} + +func mboxes(addresses []*mail.Address) []string { + e := make([]string, len(addresses)) + for i, addr := range addresses { + parts := strings.SplitN(addr.Address, "@", 2) + e[i] = parts[0] + } + return e +} + +func shortmboxes(addresses []*mail.Address) []string { + e := make([]string, len(addresses)) + for i, addr := range addresses { + parts := strings.SplitN(addr.Address, "@", 2) + parts = strings.SplitN(parts[0], ".", 2) + e[i] = parts[0] + } + return e +} + +func persons(addresses []*mail.Address) []string { + e := make([]string, len(addresses)) + for i, addr := range addresses { + e[i] = format.AddressForHumans(addr) + } + return e +} + +var units = []string{"K", "M", "G", "T"} + +func humanReadable(value int) string { + sign := "" + if value < 0 { + sign = "-" + value = -value + } + if value < 1000 { + return fmt.Sprintf("%s%d", sign, value) + } + val := float64(value) + unit := "" + for i := 0; val >= 1000 && i < len(units); i++ { + unit = units[i] + val /= 1000.0 + } + if val < 100.0 { + return fmt.Sprintf("%s%.1f%s", sign, val, unit) + } + return fmt.Sprintf("%s%.0f%s", sign, val, unit) +} + +func cwd() string { + path, err := os.Getwd() + if err != nil { + return err.Error() + } + home, err := os.UserHomeDir() + if err != nil { + return err.Error() + } + if strings.HasPrefix(path, home) { + path = strings.Replace(path, home, "~", 1) + } + return path +} + +func join(sep string, elems []string) string { + return strings.Join(elems, sep) +} + +func split(sep string, s string) []string { + return strings.Split(s, sep) +} + +// removes a signature from the piped in message +func trimSignature(message string) string { + var res strings.Builder + + input := bufio.NewScanner(strings.NewReader(message)) + + for input.Scan() { + line := input.Text() + if line == "-- " { + break + } + res.WriteString(line) + res.WriteRune('\n') + } + return res.String() +} + +func compactDir(path string) string { + return format.CompactPath(path, os.PathSeparator) +} + +type ( + Case struct{ expr, value string } + Default struct{ value string } + Exclude struct{ expr string } +) + +func (c *Case) Matches(s string) bool { return parse.MatchCache(s, c.expr) } +func (c *Case) Value() string { return c.value } +func (c *Case) Skip() bool { return false } +func (d *Default) Matches(s string) bool { return true } +func (d *Default) Value() string { return d.value } +func (d *Default) Skip() bool { return false } +func (e *Exclude) Matches(s string) bool { return parse.MatchCache(s, e.expr) } +func (e *Exclude) Value() string { return "" } +func (e *Exclude) Skip() bool { return true } + +func switch_(value string, cases ...models.Case) string { + for _, c := range cases { + if c.Matches(value) { + return c.Value() + } + } + return "" +} + +func case_(expr, value string) models.Case { + return &Case{expr: expr, value: value} +} + +func default_(value string) models.Case { + return &Default{value: value} +} + +func exclude(expr string) models.Case { + return &Exclude{expr: expr} +} + +func map_(elements []string, cases ...models.Case) []string { + mapped := make([]string, 0, len(elements)) +top: + for _, e := range elements { + for _, c := range cases { + if c.Matches(e) { + if c.Skip() { + continue top + } + e = c.Value() + break + } + } + mapped = append(mapped, e) + } + return mapped +} + +func replace(pattern, subst, value string) string { + re := regexp.MustCompile(pattern) + return re.ReplaceAllString(value, subst) +} + +func contains(substring, s string) bool { + return strings.Contains(s, substring) +} + +func hasPrefix(prefix, s string) bool { + return strings.HasPrefix(s, prefix) +} + +func head(n uint, s string) string { + r := []rune(s) + length := uint(len(r)) + if length >= n { + return string(r[:n]) + } + return s +} + +func tail(n uint, s string) string { + r := []rune(s) + length := uint(len(r)) + if length >= n { + return string(r[length-n:]) + } + return s +} + +var templateFuncs = template.FuncMap{ + "quote": quote, + "wrapText": wrapText, + "wrap": wrap, + "now": time.Now, + "dateFormat": time.Time.Format, + "toLocal": toLocal, + "exec": cmd, + "version": func() string { return version }, + "names": names, + "firstnames": firstnames, + "initials": initials, + "emails": emails, + "mboxes": mboxes, + "shortmboxes": shortmboxes, + "persons": persons, + "humanReadable": humanReadable, + "cwd": cwd, + "join": join, + "split": split, + "trimSignature": trimSignature, + "compactDir": compactDir, + "match": parse.MatchCache, + "switch": switch_, + "case": case_, + "default": default_, + "map": map_, + "exclude": exclude, + "contains": contains, + "hasPrefix": hasPrefix, + "toLower": strings.ToLower, + "toUpper": strings.ToUpper, + "replace": replace, + "head": head, + "tail": tail, +} diff --git a/lib/templates/functions_test.go b/lib/templates/functions_test.go new file mode 100644 index 0000000..3dac591 --- /dev/null +++ b/lib/templates/functions_test.go @@ -0,0 +1,157 @@ +package templates + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/emersion/go-message/mail" +) + +func TestTemplates_DifferentNamesFormats(t *testing.T) { + type testCase struct { + address mail.Address + name string + } + + cases := []testCase{ + {address: mail.Address{Name: "", Address: "john@doe.com"}, name: "john"}, + {address: mail.Address{Name: "", Address: "bill.john.doe@doe.com"}, name: "bill.john.doe"}, + {address: mail.Address{Name: "John", Address: "john@doe.com"}, name: "John"}, + {address: mail.Address{Name: "John Doe", Address: "john@doe.com"}, name: "John Doe"}, + {address: mail.Address{Name: "Bill John Doe", Address: "john@doe.com"}, name: "Bill John Doe"}, + {address: mail.Address{Name: "Doe, John", Address: "john@doe.com"}, name: "John Doe"}, + {address: mail.Address{Name: "Doe, Bill John", Address: "john@doe.com"}, name: "Bill John Doe"}, + {address: mail.Address{Name: "Schröder, Gerhard", Address: "s@g.de"}, name: "Gerhard Schröder"}, + {address: mail.Address{Name: "Buhl-Freiherr von und zu Guttenberg, Karl-Theodor Maria Nikolaus Johann Jacob Philipp Franz Joseph Sylvester", Address: "long@email.com"}, name: "Karl-Theodor Maria Nikolaus Johann Jacob Philipp Franz Joseph Sylvester Buhl-Freiherr von und zu Guttenberg"}, + {address: mail.Address{Name: "Dr. Őz-Szűcs Villő, MD, PhD, MBA (Üllői úti Klinika, Budapest, Hungary)", Address: "a@b.com"}, name: "Dr. Őz-Szűcs Villő, MD, PhD, MBA (Üllői úti Klinika, Budapest, Hungary)"}, + {address: mail.Address{Name: "International Important Conference, 2023", Address: "a@b.com"}, name: "2023 International Important Conference"}, + {address: mail.Address{Name: "A. B.C. Muscat", Address: "a@b.com"}, name: "A. B.C. Muscat"}, + {address: mail.Address{Name: "Wertram, te, K.W.", Address: "a@b.com"}, name: "Wertram, te, K.W."}, + {address: mail.Address{Name: "Harvard, John, Dr. CDC/MIT/SYSOPSYS", Address: "a@b.com"}, name: "Harvard, John, Dr. CDC/MIT/SYSOPSYS"}, + } + + for _, c := range cases { + names := names([]*mail.Address{&c.address}) + assert.Len(t, names, 1) + assert.Equal(t, c.name, names[0]) + } +} + +func TestTemplates_DifferentFirstnamesFormats(t *testing.T) { + type testCase struct { + address mail.Address + firstname string + } + + cases := []testCase{ + {address: mail.Address{Name: "", Address: "john@doe.com"}, firstname: "john"}, + {address: mail.Address{Name: "", Address: "bill.john.doe@doe.com"}, firstname: "bill"}, + {address: mail.Address{Name: "John", Address: "john@doe.com"}, firstname: "John"}, + {address: mail.Address{Name: "John Doe", Address: "john@doe.com"}, firstname: "John"}, + {address: mail.Address{Name: "Bill John Doe", Address: "john@doe.com"}, firstname: "Bill"}, + {address: mail.Address{Name: "Doe, John", Address: "john@doe.com"}, firstname: "John"}, + {address: mail.Address{Name: "Schröder, Gerhard", Address: "s@g.de"}, firstname: "Gerhard"}, + {address: mail.Address{Name: "Buhl-Freiherr von und zu Guttenberg, Karl-Theodor Maria Nikolaus Johann Jacob Philipp Franz Joseph Sylvester", Address: "long@email.com"}, firstname: "Karl-Theodor"}, + {address: mail.Address{Name: "Dr. Őz-Szűcs Villő, MD, PhD, MBA (Üllői úti Klinika, Budapest, Hungary)", Address: "a@b.com"}, firstname: "Dr."}, + {address: mail.Address{Name: "International Important Conference, 2023", Address: "a@b.com"}, firstname: "2023"}, + {address: mail.Address{Name: "A. B.C. Muscat", Address: "a@b.com"}, firstname: "A."}, + {address: mail.Address{Name: "Wertram, te, K.W.", Address: "a@b.com"}, firstname: "Wertram"}, + {address: mail.Address{Name: "Harvard, John, Dr. CDC/MIT/SYSOPSYS", Address: "a@b.com"}, firstname: "Harvard"}, + } + + for _, c := range cases { + names := firstnames([]*mail.Address{&c.address}) + assert.Len(t, names, 1) + assert.Equal(t, c.firstname, names[0]) + } +} + +func TestTemplates_InternalRearrangeNamesWithComma(t *testing.T) { + type testCase struct { + source string + res string + } + + cases := []testCase{ + {source: "John.Doe", res: "John.Doe"}, + {source: "John Doe", res: "John Doe"}, + {source: "John Bill Doe", res: "John Bill Doe"}, + {source: "Doe, John Bill", res: "John Bill Doe"}, + {source: "Doe, John-Bill", res: "John-Bill Doe"}, + {source: "Doe John, Bill", res: "Bill Doe John"}, + {source: "Schröder, Gerhard", res: "Gerhard Schröder"}, + // do not touch names with more than one comma + {source: "One, Two, Three", res: "One, Two, Three"}, + {source: "One, Two, Three, Four", res: "One, Two, Three, Four"}, + } + + for _, c := range cases { + res := rearrangeNameWithComma(c.source) + assert.Equal(t, c.res, res) + } +} + +func TestTemplates_DifferentInitialsFormats(t *testing.T) { + type testCase struct { + address mail.Address + initials string + } + + cases := []testCase{ + {address: mail.Address{Name: "", Address: "john@doe.com"}, initials: "j"}, + {address: mail.Address{Name: "", Address: "bill.john.doe@doe.com"}, initials: "b"}, + {address: mail.Address{Name: "John", Address: "john@doe.com"}, initials: "J"}, + {address: mail.Address{Name: "John Doe", Address: "john@doe.com"}, initials: "JD"}, + {address: mail.Address{Name: "Bill John Doe", Address: "john@doe.com"}, initials: "BJD"}, + {address: mail.Address{Name: "Doe, John", Address: "john@doe.com"}, initials: "JD"}, + {address: mail.Address{Name: "Doe, John Bill", Address: "john@doe.com"}, initials: "JBD"}, + {address: mail.Address{Name: "Schröder, Gerhard", Address: "s@g.de"}, initials: "GS"}, + {address: mail.Address{Name: "Buhl-Freiherr von und zu Guttenberg, Karl-Theodor Maria Nikolaus Johann Jacob Philipp Franz Joseph Sylvester", Address: "long@email.com"}, initials: "KMNJJPFJSBvuzG"}, + {address: mail.Address{Name: "Dr. Őz-Szűcs Villő, MD, PhD, MBA (Üllői úti Klinika, Budapest, Hungary)", Address: "a@b.com"}, initials: "DŐVMPM(úKBH"}, + {address: mail.Address{Name: "International Important Conference, 2023", Address: "a@b.com"}, initials: "2IIC"}, + {address: mail.Address{Name: "A. B.C. Muscat", Address: "a@b.com"}, initials: "ABM"}, + {address: mail.Address{Name: "Wertram, te, K.W.", Address: "a@b.com"}, initials: "WtK"}, + {address: mail.Address{Name: "Harvard, John, Dr. CDC/MIT/SYSOPSYS", Address: "a@b.com"}, initials: "HJDC"}, + } + + for _, c := range cases { + intls := initials([]*mail.Address{&c.address}) + assert.Len(t, intls, 1) + assert.Equal(t, c.initials, intls[0]) + } +} + +func TestTemplates_Head(t *testing.T) { + type testCase struct { + head uint + input string + output string + } + cases := []testCase{ + {head: 3, input: "abcde", output: "abc"}, + {head: 10, input: "abcde", output: "abcde"}, + } + + for _, c := range cases { + out := head(c.head, c.input) + assert.Equal(t, c.output, out) + } +} + +func TestTemplates_Tail(t *testing.T) { + type testCase struct { + tail uint + input string + output string + } + cases := []testCase{ + {tail: 2, input: "abcde", output: "de"}, + {tail: 8, input: "abcde", output: "abcde"}, + } + + for _, c := range cases { + out := tail(c.tail, c.input) + assert.Equal(t, c.output, out) + } +} diff --git a/lib/templates/template.go b/lib/templates/template.go new file mode 100644 index 0000000..4d96472 --- /dev/null +++ b/lib/templates/template.go @@ -0,0 +1,105 @@ +package templates + +import ( + "bytes" + "fmt" + "io" + "os" + "reflect" + "text/template" + + "git.sr.ht/~rjarry/aerc/lib/xdg" + "git.sr.ht/~rjarry/aerc/models" +) + +func findTemplate(templateName string, templateDirs []string) (string, error) { + for _, dir := range templateDirs { + templateFile := xdg.ExpandHome(dir, templateName) + if _, err := os.Stat(templateFile); os.IsNotExist(err) { + continue + } + return templateFile, nil + } + + return "", fmt.Errorf( + "Can't find template %q in any of %v ", templateName, templateDirs) +} + +func ParseTemplateFromFile( + name string, dirs []string, data models.TemplateData, +) (io.Reader, error) { + templateFile, err := findTemplate(name, dirs) + if err != nil { + return nil, err + } + emailTemplate, err := template.New(name). + Funcs(templateFuncs).ParseFiles(templateFile) + if err != nil { + return nil, err + } + + var body bytes.Buffer + if err := Render(emailTemplate, &body, data); err != nil { + return nil, err + } + return &body, nil +} + +func ParseTemplate(name, content string) (*template.Template, error) { + return template.New(name).Funcs(templateFuncs).Parse(content) +} + +func Render(t *template.Template, w io.Writer, data models.TemplateData) error { + return t.Execute(w, data) +} + +// builtins is a slice of keywords and functions built into the Go standard +// library for templates. Since they are not exported, they are hardcoded here. +var builtins = []string{ + // from the Go standard library: src/text/template/parse/lex.go + "block", + "break", + "continue", + "define", + "else", + "end", + "if", + "range", + "nil", + "template", + "with", + + // from the Go standard library: src/text/template/funcs.go + "and", + "call", + "html", + "index", + "slice", + "js", + "len", + "not", + "or", + "print", + "printf", + "println", + "urlquery", + "eq", + "ge", + "gt", + "le", + "lt", + "ne", +} + +func Terms() []string { + var s []string + t := reflect.TypeOf((*models.TemplateData)(nil)).Elem() + for i := 0; i < t.NumMethod(); i++ { + s = append(s, "."+t.Method(i).Name) + } + for fnStr := range templateFuncs { + s = append(s, fnStr) + } + s = append(s, builtins...) + return s +} diff --git a/lib/threadbuilder.go b/lib/threadbuilder.go new file mode 100644 index 0000000..d7373b1 --- /dev/null +++ b/lib/threadbuilder.go @@ -0,0 +1,392 @@ +package lib + +import ( + "fmt" + "sync" + "time" + + "git.sr.ht/~rjarry/aerc/lib/iterator" + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/types" + sortthread "github.com/emersion/go-imap-sortthread" + "github.com/gatherstars-com/jwz" +) + +type ThreadBuilder struct { + sync.Mutex + threadBlocks map[models.UID]jwz.Threadable + threadedUids []models.UID + threadMap map[models.UID]*types.Thread + iterFactory iterator.Factory + bySubject bool +} + +func NewThreadBuilder(i iterator.Factory, bySubject bool) *ThreadBuilder { + tb := &ThreadBuilder{ + threadBlocks: make(map[models.UID]jwz.Threadable), + iterFactory: i, + threadMap: make(map[models.UID]*types.Thread), + bySubject: bySubject, + } + return tb +} + +func (builder *ThreadBuilder) ThreadForUid(uid models.UID) (*types.Thread, error) { + builder.Lock() + defer builder.Unlock() + t, ok := builder.threadMap[uid] + var err error + if !ok { + err = fmt.Errorf("no thread found for uid '%s'", uid) + } + return t, err +} + +// Uids returns the uids in threading order +func (builder *ThreadBuilder) Uids() []models.UID { + builder.Lock() + defer builder.Unlock() + + if builder.threadedUids == nil { + return []models.UID{} + } + return builder.threadedUids +} + +// Update updates the thread builder with a new message header +func (builder *ThreadBuilder) Update(msg *models.MessageInfo) { + builder.Lock() + defer builder.Unlock() + + if msg != nil { + threadable := newThreadable(msg, builder.bySubject) + if threadable != nil { + builder.threadBlocks[msg.Uid] = threadable + } + } +} + +// Threads returns a slice of threads for the given list of uids +func (builder *ThreadBuilder) Threads(uids []models.UID, inverse bool, sort bool, +) []*types.Thread { + builder.Lock() + defer builder.Unlock() + + start := time.Now() + + threads := builder.buildAercThreads(builder.generateStructure(uids), + uids, sort) + + // sort threads according to uid ordering + builder.sortThreads(threads, uids) + + // rebuild uids from threads + builder.RebuildUids(threads, inverse) + + elapsed := time.Since(start) + log.Tracef("%d threads from %d uids created in %s", len(threads), + len(uids), elapsed) + + return threads +} + +func (builder *ThreadBuilder) generateStructure(uids []models.UID) jwz.Threadable { + jwzThreads := make([]jwz.Threadable, 0, len(builder.threadBlocks)) + for _, uid := range uids { + if thr, ok := builder.threadBlocks[uid]; ok { + jwzThreads = append(jwzThreads, thr) + } + } + + threader := jwz.NewThreader() + threadStructure, err := threader.ThreadSlice(jwzThreads) + if err != nil { + log.Errorf("failed slicing threads: %v", err) + } + return threadStructure +} + +func (builder *ThreadBuilder) buildAercThreads(structure jwz.Threadable, + uids []models.UID, sort bool, +) []*types.Thread { + threads := make([]*types.Thread, 0, len(builder.threadBlocks)) + + if structure == nil { + for _, uid := range uids { + threads = append(threads, &types.Thread{Uid: uid}) + } + } else { + + // prepare bigger function + var bigger func(l, r *types.Thread) bool + if sort { + sortMap := make(map[models.UID]int) + for i, uid := range uids { + sortMap[uid] = i + } + bigger = func(left, right *types.Thread) bool { + if left == nil || right == nil { + return false + } + return sortMap[left.Uid] > sortMap[right.Uid] + } + } else { + bigger = func(left, right *types.Thread) bool { + if left == nil || right == nil { + return false + } + return left.Uid > right.Uid + } + } + + // add uids for the unfetched messages + for _, uid := range uids { + if _, ok := builder.threadBlocks[uid]; !ok { + threads = append(threads, &types.Thread{Uid: uid}) + } + } + + // build thread tree + root := &types.Thread{} + builder.buildTree(structure, root, bigger, true) + + // copy top-level threads to thread slice + for thread := root.FirstChild; thread != nil; thread = thread.NextSibling { + thread.Parent = nil + threads = append(threads, thread) + } + + } + return threads +} + +// buildTree recursively translates the jwz threads structure into aerc threads +func (builder *ThreadBuilder) buildTree(c jwz.Threadable, parent *types.Thread, + bigger func(l, r *types.Thread) bool, rootLevel bool, +) { + if c == nil || parent == nil { + return + } + for node := c; node != nil; node = node.GetNext() { + thread := builder.newThread(node, parent, node.IsDummy()) + if rootLevel { + thread.NextSibling = parent.FirstChild + parent.FirstChild = thread + } else { + parent.InsertCmp(thread, bigger) + } + builder.buildTree(node.GetChild(), thread, bigger, node.IsDummy()) + } +} + +func (builder *ThreadBuilder) newThread(c jwz.Threadable, parent *types.Thread, + hidden bool, +) *types.Thread { + hide := 0 + if hidden { + hide += 1 + } + if threadable, ok := c.(*threadable); ok { + return &types.Thread{ + Uid: threadable.UID(), + Parent: parent, + Hidden: hide, + } + } + return nil +} + +func (builder *ThreadBuilder) sortThreads(threads []*types.Thread, orderedUids []models.UID) { + types.SortThreadsBy(threads, orderedUids) +} + +// RebuildUids rebuilds the uids from the given slice of threads +func (builder *ThreadBuilder) RebuildUids(threads []*types.Thread, inverse bool) { + uids := make([]models.UID, 0, len(threads)) + iterT := builder.iterFactory.NewIterator(threads) + for iterT.Next() { + var threaduids []models.UID + _ = iterT.Value().(*types.Thread).Walk( + func(t *types.Thread, level int, currentErr error) error { + stored, ok := builder.threadMap[t.Uid] + if ok { + // make this info persistent + t.Hidden = stored.Hidden + t.Deleted = stored.Deleted + } + builder.threadMap[t.Uid] = t + if t.Deleted || t.Hidden != 0 { + return nil + } + threaduids = append(threaduids, t.Uid) + return nil + }) + if inverse { + for j := len(threaduids) - 1; j >= 0; j-- { + uids = append(uids, threaduids[j]) + } + } else { + uids = append(uids, threaduids...) + } + } + + result := make([]models.UID, 0, len(uids)) + iterU := builder.iterFactory.NewIterator(uids) + for iterU.Next() { + result = append(result, iterU.Value().(models.UID)) + } + builder.threadedUids = result +} + +// threadable implements the jwz.threadable interface which is required for the +// jwz threading algorithm +type threadable struct { + MsgInfo *models.MessageInfo + MessageId string + Next jwz.Threadable + Parent jwz.Threadable + Child jwz.Threadable + Dummy bool + bySubject bool +} + +func newThreadable(msg *models.MessageInfo, bySubject bool) *threadable { + msgid, err := msg.MsgId() + if err != nil { + return nil + } + return &threadable{ + MessageId: msgid, + MsgInfo: msg, + Next: nil, + Parent: nil, + Child: nil, + Dummy: false, + bySubject: bySubject, + } +} + +func (t *threadable) MessageThreadID() string { + return t.MessageId +} + +func (t *threadable) MessageThreadReferences() []string { + if t.IsDummy() || t.MsgInfo == nil { + return nil + } + irp, err := t.MsgInfo.InReplyTo() + if err != nil { + irp = "" + } + refs, err := t.MsgInfo.References() + if err != nil || len(refs) == 0 { + if irp == "" { + return nil + } + refs = []string{irp} + } + return cleanRefs(t.MessageThreadID(), irp, refs) +} + +// cleanRefs cleans up the references headers for threading +// 1) message-id should not be part of the references +// 2) no message-id should occur twice (avoid circularities) +// 3) in-reply-to header should not be at the beginning +func cleanRefs(m, irp string, refs []string) []string { + considered := make(map[string]interface{}) + cleanRefs := make([]string, 0, len(refs)) + for _, r := range refs { + if _, seen := considered[r]; r != m && !seen { + considered[r] = nil + cleanRefs = append(cleanRefs, r) + } + } + if irp != "" && len(cleanRefs) > 0 { + if cleanRefs[0] == irp { + cleanRefs = append(cleanRefs[1:], irp) + } + } + return cleanRefs +} + +func (t *threadable) UID() models.UID { + if t.MsgInfo == nil { + return "" + } + return t.MsgInfo.Uid +} + +func (t *threadable) Subject() string { + if !t.bySubject || t.MsgInfo == nil || t.MsgInfo.Envelope == nil { + return "" + } + return t.MsgInfo.Envelope.Subject +} + +func (t *threadable) SimplifiedSubject() string { + if t.bySubject { + subject, _ := sortthread.GetBaseSubject(t.Subject()) + return subject + } + return "" +} + +func (t *threadable) SubjectIsReply() bool { + if t.bySubject { + _, replyOrForward := sortthread.GetBaseSubject(t.Subject()) + return replyOrForward + } + return false +} + +func (t *threadable) SetNext(next jwz.Threadable) { + t.Next = next +} + +func (t *threadable) SetChild(kid jwz.Threadable) { + t.Child = kid + if kid != nil { + kid.SetParent(t) + } +} + +func (t *threadable) SetParent(parent jwz.Threadable) { + t.Parent = parent +} + +func (t *threadable) GetNext() jwz.Threadable { + return t.Next +} + +func (t *threadable) GetChild() jwz.Threadable { + return t.Child +} + +func (t *threadable) GetParent() jwz.Threadable { + return t.Parent +} + +func (t *threadable) GetDate() time.Time { + if t.IsDummy() { + if t.GetChild() != nil { + return t.GetChild().GetDate() + } + return time.Unix(0, 0) + } + if t.MsgInfo == nil || t.MsgInfo.Envelope == nil { + return time.Unix(0, 0) + } + return t.MsgInfo.Envelope.Date +} + +func (t *threadable) MakeDummy(forID string) jwz.Threadable { + return &threadable{ + MessageId: forID, + Dummy: true, + } +} + +func (t *threadable) IsDummy() bool { + return t.Dummy +} diff --git a/lib/ui/borders.go b/lib/ui/borders.go new file mode 100644 index 0000000..fb3db6b --- /dev/null +++ b/lib/ui/borders.go @@ -0,0 +1,75 @@ +package ui + +import ( + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rockorager/vaxis" +) + +const ( + BORDER_LEFT = 1 << iota + BORDER_TOP = 1 << iota + BORDER_RIGHT = 1 << iota + BORDER_BOTTOM = 1 << iota +) + +type Bordered struct { + borders uint + content Drawable + uiConfig *config.UIConfig +} + +func NewBordered( + content Drawable, borders uint, uiConfig *config.UIConfig, +) *Bordered { + b := &Bordered{ + borders: borders, + content: content, + uiConfig: uiConfig, + } + return b +} + +func (bordered *Bordered) Children() []Drawable { + return []Drawable{bordered.content} +} + +func (bordered *Bordered) Invalidate() { + Invalidate() +} + +func (bordered *Bordered) Draw(ctx *Context) { + x := 0 + y := 0 + width := ctx.Width() + height := ctx.Height() + style := bordered.uiConfig.GetStyle(config.STYLE_BORDER) + verticalChar := bordered.uiConfig.BorderCharVertical + horizontalChar := bordered.uiConfig.BorderCharHorizontal + + if bordered.borders&BORDER_LEFT != 0 { + ctx.Fill(0, 0, 1, ctx.Height(), verticalChar, style) + x += 1 + width -= 1 + } + if bordered.borders&BORDER_TOP != 0 { + ctx.Fill(0, 0, ctx.Width(), 1, horizontalChar, style) + y += 1 + height -= 1 + } + if bordered.borders&BORDER_RIGHT != 0 { + ctx.Fill(ctx.Width()-1, 0, 1, ctx.Height(), verticalChar, style) + width -= 1 + } + if bordered.borders&BORDER_BOTTOM != 0 { + ctx.Fill(0, ctx.Height()-1, ctx.Width(), 1, horizontalChar, style) + height -= 1 + } + subctx := ctx.Subcontext(x, y, width, height) + bordered.content.Draw(subctx) +} + +func (bordered *Bordered) MouseEvent(localX int, localY int, event vaxis.Event) { + if content, ok := bordered.content.(Mouseable); ok { + content.MouseEvent(localX, localY, event) + } +} diff --git a/lib/ui/box.go b/lib/ui/box.go new file mode 100644 index 0000000..fc833b7 --- /dev/null +++ b/lib/ui/box.go @@ -0,0 +1,75 @@ +package ui + +import ( + "strings" + + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rockorager/vaxis" + "github.com/mattn/go-runewidth" +) + +type Box struct { + content Drawable + title string + borders string + uiConfig *config.UIConfig +} + +func NewBox( + content Drawable, title, borders string, uiConfig *config.UIConfig, +) *Box { + if borders == "" || len(borders) < 8 { + borders = "││┌─┐└─┘" + } + + b := &Box{ + content: content, + title: title, + borders: borders, + uiConfig: uiConfig, + } + return b +} + +func (b *Box) Draw(ctx *Context) { + w := ctx.Width() + h := ctx.Height() + + style := b.uiConfig.GetStyle(config.STYLE_BORDER) + + box := []rune(b.borders) + ctx.Fill(0, 0, 1, h, box[0], style) + ctx.Fill(w-1, 0, 1, h, box[1], style) + + ctx.Printf(0, 0, style, "%c%s%c", box[2], strings.Repeat(string(box[3]), w-2), box[4]) + ctx.Printf(0, h-1, style, "%c%s%c", box[5], strings.Repeat(string(box[6]), w-2), box[7]) + + if b.title != "" && w > 4 { + style = b.uiConfig.GetStyle(config.STYLE_TITLE) + title := runewidth.Truncate(b.title, w-4, "…") + ctx.Printf(2, 0, style, "%s", title) + } + + subctx := ctx.Subcontext(1, 1, w-2, h-2) + b.content.Draw(subctx) +} + +func (b *Box) Invalidate() { + b.content.Invalidate() +} + +func (b *Box) MouseEvent(localX int, localY int, event vaxis.Event) { + if content, ok := b.content.(Mouseable); ok { + content.MouseEvent(localX, localY, event) + } +} + +func (b *Box) Event(e vaxis.Event) bool { + if content, ok := b.content.(Interactive); ok { + return content.Event(e) + } + return false +} + +func (b *Box) Focus(_ bool) { +} diff --git a/lib/ui/context.go b/lib/ui/context.go new file mode 100644 index 0000000..aad670c --- /dev/null +++ b/lib/ui/context.go @@ -0,0 +1,133 @@ +package ui + +import ( + "fmt" + + "git.sr.ht/~rockorager/vaxis" +) + +// A context allows you to draw in a sub-region of the terminal +type Context struct { + window vaxis.Window + x, y int + onPopover func(*Popover) +} + +func (ctx *Context) Width() int { + width, _ := ctx.window.Size() + return width +} + +func (ctx *Context) Height() int { + _, height := ctx.window.Size() + return height +} + +// returns the vaxis Window for this context +func (ctx *Context) Window() vaxis.Window { + return ctx.window +} + +func NewContext(vx *vaxis.Vaxis, p func(*Popover)) *Context { + win := vx.Window() + return &Context{win, 0, 0, p} +} + +func (ctx *Context) Subcontext(x, y, width, height int) *Context { + if x < 0 || y < 0 { + panic(fmt.Errorf("Attempted to create context with negative offset")) + } + win := ctx.window.New(x, y, width, height) + return &Context{win, ctx.x + x, ctx.y + y, ctx.onPopover} +} + +func (ctx *Context) SetCell(x, y int, ch rune, style vaxis.Style) { + width, height := ctx.window.Size() + if x >= width || y >= height { + // no-op when dims are inadequate + return + } + ctx.window.SetCell(x, y, vaxis.Cell{ + Character: vaxis.Character{ + Grapheme: string(ch), + }, + Style: style, + }) +} + +func (ctx *Context) Printf(x, y int, style vaxis.Style, + format string, a ...interface{}, +) int { + width, height := ctx.window.Size() + + if x >= width || y >= height { + // no-op when dims are inadequate + return 0 + } + + str := fmt.Sprintf(format, a...) + + buf := StyledString(str) + ApplyAttrs(buf, style) + + old_x := x + + newline := func() bool { + x = old_x + y++ + return y < height + } + for _, sr := range buf.Cells { + switch sr.Grapheme { + case "\n": + if !newline() { + return buf.Len() + } + case "\r": + x = old_x + default: + ctx.window.SetCell(x, y, sr) + x += sr.Width + if x == old_x+width { + if !newline() { + return buf.Len() + } + } + } + } + + return buf.Len() +} + +func (ctx *Context) Fill(x, y, width, height int, rune rune, style vaxis.Style) { + win := ctx.window.New(x, y, width, height) + win.Fill(vaxis.Cell{ + Character: vaxis.Character{ + Grapheme: string(rune), + Width: 1, + }, + Style: style, + }) +} + +func (ctx *Context) SetCursor(x, y int, style vaxis.CursorStyle) { + ctx.window.ShowCursor(x, y, style) +} + +func (ctx *Context) HideCursor() { + ctx.window.Vx.HideCursor() +} + +func (ctx *Context) Popover(x, y, width, height int, d Drawable) { + ctx.onPopover(&Popover{ + x: ctx.x + x, + y: ctx.y + y, + width: width, + height: height, + content: d, + }) +} + +func (ctx *Context) Size() (int, int) { + return ctx.window.Size() +} diff --git a/lib/ui/fill.go b/lib/ui/fill.go new file mode 100644 index 0000000..eedb481 --- /dev/null +++ b/lib/ui/fill.go @@ -0,0 +1,24 @@ +package ui + +import "git.sr.ht/~rockorager/vaxis" + +type Fill struct { + Rune rune + Style vaxis.Style +} + +func NewFill(f rune, s vaxis.Style) Fill { + return Fill{f, s} +} + +func (f Fill) Draw(ctx *Context) { + for x := 0; x < ctx.Width(); x += 1 { + for y := 0; y < ctx.Height(); y += 1 { + ctx.SetCell(x, y, f.Rune, f.Style) + } + } +} + +func (f Fill) Invalidate() { + // no-op +} diff --git a/lib/ui/grid.go b/lib/ui/grid.go new file mode 100644 index 0000000..dfdaa09 --- /dev/null +++ b/lib/ui/grid.go @@ -0,0 +1,257 @@ +package ui + +import ( + "math" + "sync" + + "git.sr.ht/~rockorager/vaxis" +) + +type Grid struct { + rows []GridSpec + rowLayout []gridLayout + columns []GridSpec + columnLayout []gridLayout + + // Protected by mutex + cells []*GridCell + mutex sync.RWMutex +} + +const ( + SIZE_EXACT = iota + SIZE_WEIGHT = iota +) + +// Specifies the layout of a single row or column +type GridSpec struct { + // One of SIZE_EXACT or SIZE_WEIGHT + Strategy int + + // If Strategy = SIZE_EXACT, this function returns the number of cells + // this row/col shall occupy. If SIZE_WEIGHT, the space left after all + // exact rows/cols are measured is distributed amongst the remainder + // weighted by the value returned by this function. + Size func() int +} + +// Used to cache layout of each row/column +type gridLayout struct { + Offset int + Size int +} + +type GridCell struct { + Row int + Column int + RowSpan int + ColSpan int + Content Drawable +} + +func NewGrid() *Grid { + return &Grid{} +} + +// MakeGrid creates a grid with the specified number of columns and rows. Each +// cell has a size of 1. +func MakeGrid(numRows, numCols, rowStrategy, colStrategy int) *Grid { + rows := make([]GridSpec, numRows) + for i := 0; i < numRows; i++ { + rows[i] = GridSpec{rowStrategy, Const(1)} + } + cols := make([]GridSpec, numCols) + for i := 0; i < numCols; i++ { + cols[i] = GridSpec{colStrategy, Const(1)} + } + return NewGrid().Rows(rows).Columns(cols) +} + +func (cell *GridCell) At(row, col int) *GridCell { + cell.Row = row + cell.Column = col + return cell +} + +func (cell *GridCell) Span(rows, cols int) *GridCell { + cell.RowSpan = rows + cell.ColSpan = cols + return cell +} + +func (grid *Grid) Rows(spec []GridSpec) *Grid { + grid.rows = spec + return grid +} + +func (grid *Grid) Columns(spec []GridSpec) *Grid { + grid.columns = spec + return grid +} + +func (grid *Grid) Draw(ctx *Context) { + grid.reflow(ctx) + + grid.mutex.RLock() + defer grid.mutex.RUnlock() + + for _, cell := range grid.cells { + rows := grid.rowLayout[cell.Row : cell.Row+cell.RowSpan] + cols := grid.columnLayout[cell.Column : cell.Column+cell.ColSpan] + x := cols[0].Offset + y := rows[0].Offset + if x < 0 || y < 0 { + continue + } + + width := 0 + height := 0 + for _, col := range cols { + width += col.Size + } + for _, row := range rows { + height += row.Size + } + if x+width > ctx.Width() { + width = ctx.Width() - x + } + if y+height > ctx.Height() { + height = ctx.Height() - y + } + if width <= 0 || height <= 0 { + continue + } + subctx := ctx.Subcontext(x, y, width, height) + if cell.Content != nil { + cell.Content.Draw(subctx) + } + } +} + +func (grid *Grid) MouseEvent(localX int, localY int, event vaxis.Event) { + if event, ok := event.(vaxis.Mouse); ok { + + grid.mutex.RLock() + defer grid.mutex.RUnlock() + + for _, cell := range grid.cells { + rows := grid.rowLayout[cell.Row : cell.Row+cell.RowSpan] + cols := grid.columnLayout[cell.Column : cell.Column+cell.ColSpan] + x := cols[0].Offset + y := rows[0].Offset + width := 0 + height := 0 + for _, col := range cols { + width += col.Size + } + for _, row := range rows { + height += row.Size + } + if x <= localX && localX < x+width && y <= localY && localY < y+height { + switch content := cell.Content.(type) { + case MouseableDrawableInteractive: + content.MouseEvent(localX-x, localY-y, event) + case Mouseable: + content.MouseEvent(localX-x, localY-y, event) + case MouseHandler: + content.MouseEvent(localX-x, localY-y, event) + } + } + } + } +} + +func (grid *Grid) reflow(ctx *Context) { + grid.rowLayout = nil + grid.columnLayout = nil + flow := func(specs *[]GridSpec, layouts *[]gridLayout, extent int) { + exact := 0 + weight := 0 + nweights := 0 + for _, spec := range *specs { + if spec.Strategy == SIZE_EXACT { + exact += spec.Size() + } else if spec.Strategy == SIZE_WEIGHT { + nweights += 1 + weight += spec.Size() + } + } + offset := 0 + remainingExact := 0 + if weight > 0 { + remainingExact = (extent - exact) % weight + } + for _, spec := range *specs { + layout := gridLayout{Offset: offset} + if spec.Strategy == SIZE_EXACT { + layout.Size = spec.Size() + } else if spec.Strategy == SIZE_WEIGHT { + proportion := float64(spec.Size()) / float64(weight) + size := proportion * float64(extent-exact) + if remainingExact > 0 { + extraExact := int(math.Ceil(proportion * float64(remainingExact))) + layout.Size = int(math.Floor(size)) + extraExact + remainingExact -= extraExact + + } else { + layout.Size = int(math.Floor(size)) + } + } + offset += layout.Size + *layouts = append(*layouts, layout) + } + } + flow(&grid.rows, &grid.rowLayout, ctx.Height()) + flow(&grid.columns, &grid.columnLayout, ctx.Width()) +} + +func (grid *Grid) Invalidate() { + Invalidate() +} + +func (grid *Grid) AddChild(content Drawable) *GridCell { + cell := &GridCell{ + RowSpan: 1, + ColSpan: 1, + Content: content, + } + grid.mutex.Lock() + grid.cells = append(grid.cells, cell) + grid.mutex.Unlock() + grid.Invalidate() + return cell +} + +func (grid *Grid) RemoveChild(content Drawable) { + grid.mutex.Lock() + for i, cell := range grid.cells { + if cell.Content == content { + grid.cells = append(grid.cells[:i], grid.cells[i+1:]...) + break + } + } + grid.mutex.Unlock() + grid.Invalidate() +} + +func (grid *Grid) ReplaceChild(old Drawable, new Drawable) { + grid.mutex.Lock() + for i, cell := range grid.cells { + if cell.Content == old { + grid.cells[i] = &GridCell{ + RowSpan: cell.RowSpan, + ColSpan: cell.ColSpan, + Row: cell.Row, + Column: cell.Column, + Content: new, + } + break + } + } + grid.mutex.Unlock() + grid.Invalidate() +} + +func Const(i int) func() int { + return func() int { return i } +} diff --git a/lib/ui/interfaces.go b/lib/ui/interfaces.go new file mode 100644 index 0000000..3f2f950 --- /dev/null +++ b/lib/ui/interfaces.go @@ -0,0 +1,68 @@ +package ui + +import ( + "git.sr.ht/~rockorager/vaxis" +) + +// Drawable is a UI component that can draw. Unless specified, all methods must +// only be called from a single goroutine, the UI goroutine. +type Drawable interface { + // Called when this renderable should draw itself. + Draw(ctx *Context) + // Invalidates the UI. This can be called from any goroutine. + Invalidate() +} + +type Closeable interface { + Close() +} + +type Visible interface { + // Indicate that this component is visible or not + Show(bool) +} + +type Interactive interface { + // Returns true if the event was handled by this component + Event(event vaxis.Event) bool + // Indicates whether or not this control will receive input events + Focus(focus bool) +} + +type Beeper interface { + OnBeep(func()) +} + +type DrawableInteractive interface { + Drawable + Interactive +} + +type DrawableInteractiveBeeper interface { + DrawableInteractive + Beeper +} + +// A drawable which contains other drawables +type Container interface { + Drawable + // Return all of the drawables which are children of this one (do not + // recurse into your grandchildren). + Children() []Drawable +} + +type MouseHandler interface { + // Handle a mouse event which occurred at the local x and y positions + MouseEvent(localX int, localY int, event vaxis.Event) +} + +// A drawable that can be interacted with by the mouse +type Mouseable interface { + Drawable + MouseHandler +} + +type MouseableDrawableInteractive interface { + DrawableInteractive + MouseHandler +} diff --git a/lib/ui/popover.go b/lib/ui/popover.go new file mode 100644 index 0000000..5a6d054 --- /dev/null +++ b/lib/ui/popover.go @@ -0,0 +1,57 @@ +package ui + +import "git.sr.ht/~rockorager/vaxis" + +type Popover struct { + x, y, width, height int + content Drawable +} + +func (p *Popover) Draw(ctx *Context) { + var subcontext *Context + + // trim desired width to fit + width := p.width + if p.x+p.width > ctx.Width() { + width = ctx.Width() - p.x + } + + switch { + case p.y+p.height+1 < ctx.Height(): + // draw below + subcontext = ctx.Subcontext(p.x, p.y+1, width, p.height) + case p.y-p.height >= 0: + // draw above + subcontext = ctx.Subcontext(p.x, p.y-p.height, width, p.height) + default: + // can't fit entirely above or below, so find the largest available + // vertical space and shrink to fit + if p.y > ctx.Height()-p.y { + // there is more space above than below + height := p.y + subcontext = ctx.Subcontext(p.x, 0, width, height) + } else { + // there is more space below than above + height := ctx.Height() - p.y + subcontext = ctx.Subcontext(p.x, p.y+1, width, height-1) + } + } + p.content.Draw(subcontext) +} + +func (p *Popover) Event(e vaxis.Event) bool { + if di, ok := p.content.(DrawableInteractive); ok { + return di.Event(e) + } + return false +} + +func (p *Popover) Focus(f bool) { + if di, ok := p.content.(DrawableInteractive); ok { + di.Focus(f) + } +} + +func (p *Popover) Invalidate() { + Invalidate() +} diff --git a/lib/ui/stack.go b/lib/ui/stack.go new file mode 100644 index 0000000..890ab27 --- /dev/null +++ b/lib/ui/stack.go @@ -0,0 +1,64 @@ +package ui + +import ( + "fmt" + + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rockorager/vaxis" +) + +type Stack struct { + children []Drawable + uiConfig *config.UIConfig +} + +func NewStack(uiConfig *config.UIConfig) *Stack { + return &Stack{uiConfig: uiConfig} +} + +func (stack *Stack) Children() []Drawable { + return stack.children +} + +func (stack *Stack) Invalidate() { + Invalidate() +} + +func (stack *Stack) Draw(ctx *Context) { + if len(stack.children) > 0 { + stack.Peek().Draw(ctx) + } else { + ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', + stack.uiConfig.GetStyle(config.STYLE_STACK)) + } +} + +func (stack *Stack) MouseEvent(localX int, localY int, event vaxis.Event) { + if len(stack.children) > 0 { + if element, ok := stack.Peek().(Mouseable); ok { + element.MouseEvent(localX, localY, event) + } + } +} + +func (stack *Stack) Push(d Drawable) { + stack.children = append(stack.children, d) + stack.Invalidate() +} + +func (stack *Stack) Pop() Drawable { + if len(stack.children) == 0 { + panic(fmt.Errorf("Tried to pop from an empty UI stack")) + } + d := stack.children[len(stack.children)-1] + stack.children = stack.children[:len(stack.children)-1] + stack.Invalidate() + return d +} + +func (stack *Stack) Peek() Drawable { + if len(stack.children) == 0 { + panic(fmt.Errorf("Tried to peek from an empty stack")) + } + return stack.children[len(stack.children)-1] +} diff --git a/lib/ui/string.go b/lib/ui/string.go new file mode 100644 index 0000000..407c996 --- /dev/null +++ b/lib/ui/string.go @@ -0,0 +1,142 @@ +package ui + +import ( + "git.sr.ht/~rockorager/vaxis" +) + +func StyledString(s string) *vaxis.StyledString { + return state.vx.NewStyledString(s, vaxis.Style{}) +} + +// Applies a style to a string. Any currently applied styles will not be overwritten +func ApplyStyle(style vaxis.Style, str string) string { + ss := StyledString(str) + d := vaxis.Style{} + for i, sr := range ss.Cells { + if sr.Style == d { + sr.Style = style + ss.Cells[i] = sr + } + } + return ss.Encode() +} + +// PadLeft inserts blank spaces at the beginning of the StyledString to produce +// a string of the provided width +func PadLeft(ss *vaxis.StyledString, width int) { + w := ss.Len() + if w >= width { + return + } + cell := vaxis.Cell{ + Character: vaxis.Character{ + Grapheme: " ", + Width: 1, + }, + } + w = width - w + cells := make([]vaxis.Cell, 0, len(ss.Cells)+w) + for w > 0 { + cells = append(cells, cell) + w -= 1 + } + cells = append(cells, ss.Cells...) + ss.Cells = cells +} + +// PadLeft inserts blank spaces at the end of the StyledString to produce +// a string of the provided width +func PadRight(ss *vaxis.StyledString, width int) { + w := ss.Len() + if w >= width { + return + } + cell := vaxis.Cell{ + Character: vaxis.Character{ + Grapheme: " ", + Width: 1, + }, + } + w = width - w + for w > 0 { + w -= 1 + ss.Cells = append(ss.Cells, cell) + } +} + +// ApplyAttrs applies the style, and if another style is present ORs the +// attributes +func ApplyAttrs(ss *vaxis.StyledString, style vaxis.Style) { + for i, cell := range ss.Cells { + if style.Foreground != 0 { + cell.Style.Foreground = style.Foreground + } + if style.Background != 0 { + cell.Style.Background = style.Background + } + cell.Style.Attribute |= style.Attribute + if style.UnderlineColor != 0 { + cell.Style.UnderlineColor = style.UnderlineColor + } + if style.UnderlineStyle != vaxis.UnderlineOff { + cell.Style.UnderlineStyle = style.UnderlineStyle + } + ss.Cells[i] = cell + } +} + +// Truncates the styled string on the right and inserts a '…' as the last +// character +func Truncate(ss *vaxis.StyledString, width int) { + if ss.Len() <= width { + return + } + cells := make([]vaxis.Cell, 0, len(ss.Cells)) + total := 0 + for _, cell := range ss.Cells { + if total+cell.Width >= width { + // we can't fit this cell so put in our truncator + cells = append(cells, vaxis.Cell{ + Character: vaxis.Character{ + Grapheme: "…", + Width: 1, + }, + Style: cell.Style, + }) + break + } + total += cell.Width + cells = append(cells, cell) + } + ss.Cells = cells +} + +// TruncateHead truncates the left side of the string and inserts '…' as the +// first character +func TruncateHead(ss *vaxis.StyledString, width int) { + l := ss.Len() + if l <= width { + return + } + offset := l - width + cells := make([]vaxis.Cell, 0, len(ss.Cells)) + cells = append(cells, vaxis.Cell{ + Character: vaxis.Character{ + Grapheme: "…", + Width: 1, + }, + }) + total := 0 + for _, cell := range ss.Cells { + total += cell.Width + if total < offset { + // we always have at least one for our truncator. We + // copy this cells style to it so that it retains the + // style information from the first printed cell + cells[0].Style = cell.Style + continue + } + cells = append(cells, cell) + } + ss.Cells = cells +} diff --git a/lib/ui/tab.go b/lib/ui/tab.go new file mode 100644 index 0000000..49bb3d9 --- /dev/null +++ b/lib/ui/tab.go @@ -0,0 +1,504 @@ +package ui + +import ( + "sync" + + "github.com/mattn/go-runewidth" + + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rockorager/vaxis" +) + +const tabRuneWidth int = 32 // TODO: make configurable + +type Tabs struct { + tabs []*Tab + TabStrip *TabStrip + TabContent *TabContent + curIndex int + history []*Tab + m sync.Mutex + + ui func(d Drawable) *config.UIConfig + + parent *Tabs //nolint:structcheck // used within this file + CloseTab func(index int) +} + +type Tab struct { + Content Drawable + Name string + pinned bool + indexBeforePin int + title string +} + +func (t *Tab) SetTitle(s string) { + t.title = s +} + +func (t *Tab) displayName(pinMarker string) string { + name := t.Name + if t.title != "" { + name = t.title + } + if t.pinned { + name = pinMarker + name + } + return name +} + +type ( + TabStrip Tabs + TabContent Tabs +) + +func NewTabs(ui func(d Drawable) *config.UIConfig) *Tabs { + tabs := &Tabs{ui: ui} + tabs.TabStrip = (*TabStrip)(tabs) + tabs.TabStrip.parent = tabs + tabs.TabContent = (*TabContent)(tabs) + tabs.TabContent.parent = tabs + return tabs +} + +func (tabs *Tabs) Add(content Drawable, name string, background bool) *Tab { + tab := &Tab{ + Content: content, + Name: name, + } + tabs.tabs = append(tabs.tabs, tab) + if !background { + tabs.selectPriv(len(tabs.tabs)-1, true) + } + return tab +} + +func (tabs *Tabs) Names() []string { + var names []string + tabs.m.Lock() + for _, tab := range tabs.tabs { + names = append(names, tab.Name) + } + tabs.m.Unlock() + return names +} + +func (tabs *Tabs) Remove(content Drawable) { + tabs.m.Lock() + defer tabs.m.Unlock() + index := -1 + for i, tab := range tabs.tabs { + if tab.Content == content { + index = i + break + } + } + if index == -1 { + return + } + + tab := tabs.tabs[index] + if vis, ok := tab.Content.(Visible); ok { + vis.Show(false) + } + if vis, ok := tab.Content.(Interactive); ok { + vis.Focus(false) + } + tabs.tabs = append(tabs.tabs[:index], tabs.tabs[index+1:]...) + tabs.removeHistory(tab) + + if index == tabs.curIndex { + // only pop the tab history if the closing tab is selected + prevIndex, ok := tabs.popHistory() + if !ok { + if tabs.curIndex < len(tabs.tabs) { + // history is empty, select tab on the right if possible + prevIndex = tabs.curIndex + } else { + // if removing the last tab, select the now last tab + prevIndex = len(tabs.tabs) - 1 + } + } + tabs.selectPriv(prevIndex, false) + } else if index < tabs.curIndex { + // selected tab is now one to the left of where it was + tabs.selectPriv(tabs.curIndex-1, false) + } + Invalidate() +} + +func (tabs *Tabs) Replace(contentSrc Drawable, contentTarget Drawable, name string) { + tabs.m.Lock() + defer tabs.m.Unlock() + for i, tab := range tabs.tabs { + if tab.Content == contentSrc { + if vis, ok := tab.Content.(Visible); ok { + vis.Show(false) + } + if vis, ok := tab.Content.(Interactive); ok { + vis.Focus(false) + } + tab.Content = contentTarget + tabs.selectPriv(i, false) + Invalidate() + break + } + } +} + +func (tabs *Tabs) Get(index int) *Tab { + tabs.m.Lock() + defer tabs.m.Unlock() + if index < 0 || index >= len(tabs.tabs) { + return nil + } + return tabs.tabs[index] +} + +func (tabs *Tabs) Selected() *Tab { + tabs.m.Lock() + defer tabs.m.Unlock() + if tabs.curIndex < 0 || tabs.curIndex >= len(tabs.tabs) { + return nil + } + return tabs.tabs[tabs.curIndex] +} + +func (tabs *Tabs) Select(index int) bool { + tabs.m.Lock() + defer tabs.m.Unlock() + return tabs.selectPriv(index, true) +} + +func (tabs *Tabs) selectPriv(index int, unselectPrev bool) bool { + if index < 0 || index >= len(tabs.tabs) { + return false + } + + // only push valid tabs onto the history + if unselectPrev && tabs.curIndex < len(tabs.tabs) { + prev := tabs.tabs[tabs.curIndex] + if vis, ok := prev.Content.(Visible); ok { + vis.Show(false) + } + if vis, ok := prev.Content.(Interactive); ok { + vis.Focus(false) + } + tabs.pushHistory(prev) + } + + next := tabs.tabs[index] + if vis, ok := next.Content.(Visible); ok { + vis.Show(true) + } + if vis, ok := next.Content.(Interactive); ok { + vis.Focus(true) + } + tabs.curIndex = index + Invalidate() + + return true +} + +func (tabs *Tabs) SelectName(name string) bool { + tabs.m.Lock() + defer tabs.m.Unlock() + for i, tab := range tabs.tabs { + if tab.Name == name { + return tabs.selectPriv(i, true) + } + } + return false +} + +func (tabs *Tabs) SelectPrevious() bool { + tabs.m.Lock() + defer tabs.m.Unlock() + index, ok := tabs.popHistory() + if !ok { + return false + } + return tabs.selectPriv(index, true) +} + +func (tabs *Tabs) SelectOffset(offset int) { + tabs.m.Lock() + tabCount := len(tabs.tabs) + newIndex := (tabs.curIndex + offset) % tabCount + if newIndex < 0 { + // Handle negative offsets correctly + newIndex += tabCount + } + tabs.selectPriv(newIndex, true) + tabs.m.Unlock() +} + +func (tabs *Tabs) MoveTab(to int, relative bool) { + tabs.m.Lock() + tabs.moveTabPriv(to, relative) + tabs.m.Unlock() +} + +func (tabs *Tabs) moveTabPriv(to int, relative bool) { + from := tabs.curIndex + + if relative { + to = from + to + } + if to < 0 { + to = 0 + } + if to >= len(tabs.tabs) { + to = len(tabs.tabs) - 1 + } + tabs.tabs[from], tabs.tabs[to] = tabs.tabs[to], tabs.tabs[from] + tabs.curIndex = to + Invalidate() +} + +func (tabs *Tabs) PinTab() { + tabs.m.Lock() + defer tabs.m.Unlock() + if tabs.tabs[tabs.curIndex].pinned { + return + } + + pinEnd := len(tabs.tabs) + for i, t := range tabs.tabs { + if !t.pinned { + pinEnd = i + break + } + } + + for _, t := range tabs.tabs { + if t.pinned && t.indexBeforePin > tabs.curIndex-pinEnd { + t.indexBeforePin -= 1 + } + } + + tabs.tabs[tabs.curIndex].pinned = true + tabs.tabs[tabs.curIndex].indexBeforePin = tabs.curIndex - pinEnd + + tabs.moveTabPriv(pinEnd, false) +} + +func (tabs *Tabs) UnpinTab() { + tabs.m.Lock() + defer tabs.m.Unlock() + if !tabs.tabs[tabs.curIndex].pinned { + return + } + + pinEnd := len(tabs.tabs) + for i, t := range tabs.tabs { + if i != tabs.curIndex && t.pinned && t.indexBeforePin > tabs.tabs[tabs.curIndex].indexBeforePin { + t.indexBeforePin += 1 + } + if !t.pinned { + pinEnd = i + break + } + } + + tabs.tabs[tabs.curIndex].pinned = false + + tabs.moveTabPriv(tabs.tabs[tabs.curIndex].indexBeforePin+pinEnd-1, false) +} + +func (tabs *Tabs) NextTab() { + tabs.m.Lock() + next := tabs.curIndex + 1 + if next >= len(tabs.tabs) { + next = 0 + } + tabs.selectPriv(next, true) + tabs.m.Unlock() +} + +func (tabs *Tabs) PrevTab() { + tabs.m.Lock() + next := tabs.curIndex - 1 + if next < 0 { + next = len(tabs.tabs) - 1 + } + tabs.selectPriv(next, true) + tabs.m.Unlock() +} + +const maxHistory = 256 + +func (tabs *Tabs) pushHistory(tab *Tab) { + tabs.history = append(tabs.history, tab) + if len(tabs.history) > maxHistory { + tabs.history = tabs.history[1:] + } +} + +func (tabs *Tabs) popHistory() (int, bool) { + if len(tabs.history) == 0 { + return -1, false + } + tab := tabs.history[len(tabs.history)-1] + tabs.history = tabs.history[:len(tabs.history)-1] + index := -1 + for i, t := range tabs.tabs { + if t == tab { + index = i + break + } + } + if index == -1 { + return -1, false + } + return index, true +} + +func (tabs *Tabs) removeHistory(tab *Tab) { + var newHist []*Tab + for _, item := range tabs.history { + if item != tab { + newHist = append(newHist, item) + } + } + tabs.history = newHist +} + +// TODO: Color repository +func (strip *TabStrip) Draw(ctx *Context) { + x := 0 + strip.parent.m.Lock() + for i, tab := range strip.tabs { + uiConfig := strip.ui(tab.Content) + if uiConfig == nil { + uiConfig = config.Ui + } + style := uiConfig.GetStyle(config.STYLE_TAB) + if strip.curIndex == i { + style = uiConfig.GetStyleSelected(config.STYLE_TAB) + } + tabWidth := tabRuneWidth + if ctx.Width()-x < tabWidth { + tabWidth = ctx.Width() - x - 2 + } + name := tab.displayName(uiConfig.PinnedTabMarker) + trunc := runewidth.Truncate(name, tabWidth, "…") + x += ctx.Printf(x, 0, style, " %s ", trunc) + if x >= ctx.Width() { + break + } + } + strip.parent.m.Unlock() + ctx.Fill(x, 0, ctx.Width()-x, 1, ' ', + config.Ui.GetStyle(config.STYLE_TAB)) +} + +func (strip *TabStrip) Invalidate() { + Invalidate() +} + +func (strip *TabStrip) MouseEvent(localX int, localY int, event vaxis.Event) { + strip.parent.m.Lock() + defer strip.parent.m.Unlock() + changeFocus := func(focus bool) { + interactive, ok := strip.parent.tabs[strip.parent.curIndex].Content.(Interactive) + if ok { + interactive.Focus(focus) + } + } + unfocus := func() { changeFocus(false) } + refocus := func() { changeFocus(true) } + if event, ok := event.(vaxis.Mouse); ok { + switch event.Button { + case vaxis.MouseLeftButton: + selectedTab, ok := strip.clicked(localX, localY) + if !ok || selectedTab == strip.parent.curIndex { + return + } + unfocus() + strip.parent.selectPriv(selectedTab, true) + refocus() + case vaxis.MouseWheelDown: + unfocus() + index := strip.parent.curIndex + 1 + if index >= len(strip.parent.tabs) { + index = 0 + } + strip.parent.selectPriv(index, true) + refocus() + case vaxis.MouseWheelUp: + unfocus() + index := strip.parent.curIndex - 1 + if index < 0 { + index = len(strip.parent.tabs) - 1 + } + strip.parent.selectPriv(index, true) + refocus() + case vaxis.MouseMiddleButton: + selectedTab, ok := strip.clicked(localX, localY) + if !ok { + return + } + unfocus() + strip.parent.m.Unlock() + strip.parent.CloseTab(selectedTab) + strip.parent.m.Lock() + refocus() + } + } +} + +func (strip *TabStrip) clicked(mouseX int, mouseY int) (int, bool) { + x := 0 + for i, tab := range strip.tabs { + uiConfig := strip.ui(tab.Content) + if uiConfig == nil { + uiConfig = config.Ui + } + name := tab.displayName(uiConfig.PinnedTabMarker) + trunc := runewidth.Truncate(name, tabRuneWidth, "…") + length := runewidth.StringWidth(trunc) + 2 + if x <= mouseX && mouseX < x+length { + return i, true + } + x += length + } + return 0, false +} + +func (content *TabContent) Children() []Drawable { + content.parent.m.Lock() + children := make([]Drawable, len(content.tabs)) + for i, tab := range content.tabs { + children[i] = tab.Content + } + content.parent.m.Unlock() + return children +} + +func (content *TabContent) Draw(ctx *Context) { + content.parent.m.Lock() + if content.curIndex >= len(content.tabs) { + width := ctx.Width() + height := ctx.Height() + ctx.Fill(0, 0, width, height, ' ', + config.Ui.GetStyle(config.STYLE_TAB)) + } + tab := content.tabs[content.curIndex] + content.parent.m.Unlock() + tab.Content.Draw(ctx) +} + +func (content *TabContent) MouseEvent(localX int, localY int, event vaxis.Event) { + content.parent.m.Lock() + tab := content.tabs[content.curIndex] + content.parent.m.Unlock() + if tabContent, ok := tab.Content.(Mouseable); ok { + tabContent.MouseEvent(localX, localY, event) + } +} + +func (content *TabContent) Invalidate() { + Invalidate() +} diff --git a/lib/ui/table.go b/lib/ui/table.go new file mode 100644 index 0000000..3f5fc4b --- /dev/null +++ b/lib/ui/table.go @@ -0,0 +1,220 @@ +package ui + +import ( + "math" + "regexp" + + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rockorager/vaxis" + "github.com/mattn/go-runewidth" +) + +type Table struct { + Columns []Column + Rows []Row + Height int + // Optional callback that allows customizing the default drawing routine + // of table rows. If true is returned, the default routine is skipped. + CustomDraw func(t *Table, row int, c *Context) bool + // Optional callback that allows returning a custom style for the row. + GetRowStyle func(t *Table, row int) vaxis.Style + + // true if at least one column has WIDTH_FIT + autoFitWidths bool + // if false, widths need to be computed before drawing + widthsComputed bool +} + +type Column struct { + Offset int + Width int + Def *config.ColumnDef + Separator string +} + +type Row struct { + Cells []string + Priv interface{} +} + +func NewTable( + height int, + columnDefs []*config.ColumnDef, separator string, + customDraw func(*Table, int, *Context) bool, + getRowStyle func(*Table, int) vaxis.Style, +) Table { + if customDraw == nil { + customDraw = func(*Table, int, *Context) bool { return false } + } + if getRowStyle == nil { + getRowStyle = func(*Table, int) vaxis.Style { + return vaxis.Style{} + } + } + columns := make([]Column, len(columnDefs)) + autoFitWidths := false + for c, col := range columnDefs { + if col.Flags.Has(config.WIDTH_FIT) { + autoFitWidths = true + } + columns[c] = Column{Def: col} + if c != len(columns)-1 { + // set separator for all columns except the last one + columns[c].Separator = separator + } + } + return Table{ + Columns: columns, + Height: height, + CustomDraw: customDraw, + GetRowStyle: getRowStyle, + autoFitWidths: autoFitWidths, + } +} + +// add a row to the table, returns true when the table is full +func (t *Table) AddRow(cells []string, priv interface{}) bool { + if len(cells) != len(t.Columns) { + panic("invalid number of cells") + } + if len(t.Rows) >= t.Height { + return true + } + t.Rows = append(t.Rows, Row{Cells: cells, Priv: priv}) + if t.autoFitWidths { + t.widthsComputed = false + } + return len(t.Rows) >= t.Height +} + +func (t *Table) computeWidths(width int) { + contentMaxWidths := make([]int, len(t.Columns)) + if t.autoFitWidths { + for _, row := range t.Rows { + for c := range t.Columns { + buf := StyledString(row.Cells[c]) + if buf.Len() > contentMaxWidths[c] { + contentMaxWidths[c] = buf.Len() + } + } + } + } + + nonFixed := width + autoWidthCount := 0 + for c := range t.Columns { + col := &t.Columns[c] + switch { + case col.Def.Flags.Has(config.WIDTH_FIT): + col.Width = contentMaxWidths[c] + // compensate for exact width columns + col.Width += runewidth.StringWidth(col.Separator) + case col.Def.Flags.Has(config.WIDTH_EXACT): + col.Width = int(math.Round(col.Def.Width)) + // compensate for exact width columns + col.Width += runewidth.StringWidth(col.Separator) + case col.Def.Flags.Has(config.WIDTH_AUTO): + col.Width = 0 + autoWidthCount += 1 + case col.Def.Flags.Has(config.WIDTH_FRACTION): + col.Width = int(math.Round(float64(width) * col.Def.Width)) + } + nonFixed -= col.Width + } + + autoWidth := 0 + if autoWidthCount > 0 && nonFixed > 0 { + autoWidth = nonFixed / autoWidthCount + if autoWidth == 0 { + autoWidth = 1 + } + } + + offset := 0 + remain := width + for c := range t.Columns { + col := &t.Columns[c] + if col.Def.Flags.Has(config.WIDTH_AUTO) && autoWidth > 0 { + col.Width = autoWidth + if nonFixed >= 2*autoWidth { + nonFixed -= autoWidth + } + } + if remain == 0 { + // column is outside of screen + col.Width = -1 + } else if col.Width > remain { + // limit width to avoid overflow + col.Width = remain + } + remain -= col.Width + col.Offset = offset + offset += col.Width + // reserve room for separator + col.Width -= runewidth.StringWidth(col.Separator) + } +} + +var metaCharsRegexp = regexp.MustCompile(`[\t\r\f\n\v]`) + +func (col *Column) alignCell(cell string) string { + cell = metaCharsRegexp.ReplaceAllString(cell, " ") + buf := StyledString(cell) + width := buf.Len() + + switch { + case col.Def.Flags.Has(config.ALIGN_LEFT): + if width < col.Width { + PadRight(buf, col.Width) + cell = buf.Encode() + } else if width > col.Width { + Truncate(buf, col.Width) + cell = buf.Encode() + } + case col.Def.Flags.Has(config.ALIGN_CENTER): + if width < col.Width { + pad := col.Width - width + PadLeft(buf, col.Width-(pad/2)) + PadRight(buf, col.Width) + cell = buf.Encode() + } else if width > col.Width { + Truncate(buf, col.Width) + cell = buf.Encode() + } + case col.Def.Flags.Has(config.ALIGN_RIGHT): + if width < col.Width { + PadLeft(buf, col.Width) + cell = buf.Encode() + } else if width > col.Width { + TruncateHead(buf, col.Width) + cell = buf.Encode() + } + } + + return cell +} + +func (t *Table) Draw(ctx *Context) { + if !t.widthsComputed { + t.computeWidths(ctx.Width()) + t.widthsComputed = true + } + for r, row := range t.Rows { + if t.CustomDraw(t, r, ctx) { + continue + } + for c, col := range t.Columns { + if col.Width == -1 { + // column overflows screen width + continue + } + cell := col.alignCell(row.Cells[c]) + style := t.GetRowStyle(t, r) + + buf := StyledString(cell) + ApplyAttrs(buf, style) + cell = buf.Encode() + ctx.Printf(col.Offset, r, style, "%s%s", cell, col.Separator) + } + } +} diff --git a/lib/ui/text.go b/lib/ui/text.go new file mode 100644 index 0000000..19a0b90 --- /dev/null +++ b/lib/ui/text.go @@ -0,0 +1,54 @@ +package ui + +import ( + "git.sr.ht/~rockorager/vaxis" + "github.com/mattn/go-runewidth" +) + +const ( + TEXT_LEFT = iota + TEXT_CENTER = iota + TEXT_RIGHT = iota +) + +type Text struct { + text string + strategy uint + style vaxis.Style +} + +func NewText(text string, style vaxis.Style) *Text { + return &Text{ + text: text, + style: style, + } +} + +func (t *Text) Text(text string) *Text { + t.text = text + t.Invalidate() + return t +} + +func (t *Text) Strategy(strategy uint) *Text { + t.strategy = strategy + t.Invalidate() + return t +} + +func (t *Text) Draw(ctx *Context) { + size := runewidth.StringWidth(t.text) + x := 0 + if t.strategy == TEXT_CENTER { + x = (ctx.Width() - size) / 2 + } + if t.strategy == TEXT_RIGHT { + x = ctx.Width() - size + } + ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', t.style) + ctx.Printf(x, 0, t.style, "%s", t.text) +} + +func (t *Text) Invalidate() { + Invalidate() +} diff --git a/lib/ui/textinput.go b/lib/ui/textinput.go new file mode 100644 index 0000000..08fc2c9 --- /dev/null +++ b/lib/ui/textinput.go @@ -0,0 +1,621 @@ +package ui + +import ( + "context" + "math" + "strings" + "sync" + "time" + + "github.com/mattn/go-runewidth" + + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/go-opt/v2" + "git.sr.ht/~rockorager/vaxis" +) + +// TODO: Attach history providers + +type TextInput struct { + sync.Mutex + cells int + ctx *Context + focus bool + index int + password bool + prompt string + scroll int + text []vaxis.Character + change []func(ti *TextInput) + focusLost []func(ti *TextInput) + tabcomplete func(ctx context.Context, s string) ([]opt.Completion, string) + tabcompleteCancel context.CancelFunc + completions []opt.Completion + prefix string + completeIndex int + completeDelay time.Duration + completeDebouncer *time.Timer + completeMinChars int + completeKey *config.KeyStroke + uiConfig *config.UIConfig +} + +// Creates a new TextInput. TextInputs will render a "textbox" in the entire +// context they're given, and process keypresses to build a string from user +// input. +func NewTextInput(text string, ui *config.UIConfig) *TextInput { + chars := vaxis.Characters(text) + return &TextInput{ + cells: -1, + text: chars, + index: len(chars), + uiConfig: ui, + tabcompleteCancel: func() {}, + } +} + +func (ti *TextInput) Password(password bool) *TextInput { + ti.password = password + return ti +} + +func (ti *TextInput) Prompt(prompt string) *TextInput { + ti.prompt = prompt + return ti +} + +func (ti *TextInput) TabComplete( + tabcomplete func(ctx context.Context, s string) ([]opt.Completion, string), + d time.Duration, minChars int, key *config.KeyStroke, +) *TextInput { + ti.tabcomplete = tabcomplete + ti.completeDelay = d + ti.completeMinChars = minChars + ti.completeKey = key + return ti +} + +func (ti *TextInput) String() string { + return charactersToString(ti.text) +} + +func (ti *TextInput) StringLeft() string { + if ti.index > len(ti.text) { + ti.index = len(ti.text) + } + left := ti.text[:ti.index] + return charactersToString(left) +} + +func (ti *TextInput) StringRight() string { + if ti.index >= len(ti.text) { + return "" + } + right := ti.text[ti.index:] + return charactersToString(right) +} + +func charactersToString(chars []vaxis.Character) string { + buf := strings.Builder{} + for _, ch := range chars { + buf.WriteString(ch.Grapheme) + } + return buf.String() +} + +func (ti *TextInput) Set(value string) *TextInput { + ti.text = vaxis.Characters(value) + ti.index = len(ti.text) + ti.scroll = 0 + return ti +} + +func (ti *TextInput) Invalidate() { + Invalidate() +} + +func (ti *TextInput) Draw(ctx *Context) { + scroll := 0 + if ti.focus { + ti.ensureScroll() + scroll = ti.scroll + } + ti.ctx = ctx // gross + + defaultStyle := ti.uiConfig.GetStyle(config.STYLE_DEFAULT) + ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', defaultStyle) + + text := ti.text[scroll:] + sindex := ti.index - scroll + if ti.password { + x := ctx.Printf(0, 0, defaultStyle, "%s", ti.prompt) + cells := len(ti.text) + ctx.Fill(x, 0, cells, 1, '*', defaultStyle) + } else { + ctx.Printf(0, 0, defaultStyle, "%s%s", ti.prompt, charactersToString(text)) + } + cells := runewidth.StringWidth(charactersToString(text[:sindex]) + ti.prompt) + if ti.focus { + ctx.SetCursor(cells, 0, vaxis.CursorDefault) + ti.drawPopover(ctx) + } +} + +func (ti *TextInput) drawPopover(ctx *Context) { + if len(ti.completions) == 0 { + return + } + + valWidth := 0 + descWidth := 0 + for _, c := range ti.completions { + valWidth = max(valWidth, runewidth.StringWidth(unquote(c.Value))) + descWidth = max(descWidth, runewidth.StringWidth(c.Description)) + } + descWidth = min(descWidth, 80) + // one space padding + width := 1 + valWidth + if descWidth != 0 { + // two spaces padding + parentheses + width += 2 + descWidth + 2 + } + // one space padding + gutter + width += 2 + + cmp := &completions{ti: ti, valWidth: valWidth, descWidth: descWidth} + height := len(ti.completions) + + pos := len(ti.prefix) - ti.scroll + if pos+width > ctx.Width() { + pos = ctx.Width() - width + } + if pos < 0 { + pos = 0 + } + + ctx.Popover(pos, 0, width, height, cmp) +} + +func (ti *TextInput) MouseEvent(localX int, localY int, event vaxis.Event) { + if event, ok := event.(vaxis.Mouse); ok { + if event.Button == vaxis.MouseLeftButton { + if localX >= len(ti.prompt)+1 && localX <= len(ti.text[ti.scroll:])+len(ti.prompt)+1 { + ti.index = localX - len(ti.prompt) - 1 + ti.ensureScroll() + ti.Invalidate() + } + } + } +} + +func (ti *TextInput) Focus(focus bool) { + if ti.focus && !focus { + ti.onFocusLost() + } + ti.focus = focus + if focus && ti.ctx != nil { + cells := runewidth.StringWidth(charactersToString(ti.text[:ti.index])) + ti.ctx.SetCursor(cells+1, 0, vaxis.CursorDefault) + } else if !focus && ti.ctx != nil { + ti.ctx.HideCursor() + } +} + +func (ti *TextInput) ensureScroll() { + if ti.ctx == nil { + return + } + w := ti.ctx.Width() - len(ti.prompt) + if ti.index >= ti.scroll+w { + ti.scroll = ti.index - w + 1 + } + if ti.index < ti.scroll { + ti.scroll = ti.index + } +} + +func (ti *TextInput) insert(ch vaxis.Character) { + left := ti.text[:ti.index] + right := ti.text[ti.index:] + ti.text = append(left, append([]vaxis.Character{ch}, right...)...) //nolint:gocritic // intentional append to different slice + ti.index++ + ti.ensureScroll() + ti.Invalidate() + ti.onChange() +} + +func (ti *TextInput) deleteWord() { + if len(ti.text) == 0 || ti.index <= 0 { + return + } + separators := "/'\"" + i := ti.index - 1 + for i >= 0 && ti.text[i].Grapheme == " " { + i-- + } + if i >= 0 && strings.Contains(separators, ti.text[i].Grapheme) { + for i >= 0 && strings.Contains(separators, ti.text[i].Grapheme) { + i-- + } + } else { + separators += " " + for i >= 0 && !strings.Contains(separators, ti.text[i].Grapheme) { + i-- + } + } + ti.text = append(ti.text[:i+1], ti.text[ti.index:]...) + ti.index = i + 1 + ti.ensureScroll() + ti.Invalidate() + ti.onChange() +} + +func (ti *TextInput) deleteLineForward() { + if len(ti.text) == 0 || len(ti.text) == ti.index { + return + } + + ti.text = ti.text[:ti.index] + ti.ensureScroll() + ti.Invalidate() + ti.onChange() +} + +func (ti *TextInput) deleteLineBackward() { + if len(ti.text) == 0 || ti.index == 0 { + return + } + + ti.text = ti.text[ti.index:] + ti.index = 0 + ti.ensureScroll() + ti.Invalidate() + ti.onChange() +} + +func (ti *TextInput) deleteChar() { + if len(ti.text) > 0 && ti.index != len(ti.text) { + ti.text = append(ti.text[:ti.index], ti.text[ti.index+1:]...) + ti.ensureScroll() + ti.Invalidate() + ti.onChange() + } +} + +func (ti *TextInput) backspace() { + if len(ti.text) > 0 && ti.index != 0 { + ti.text = append(ti.text[:ti.index-1], ti.text[ti.index:]...) + ti.index-- + ti.ensureScroll() + ti.Invalidate() + ti.onChange() + } +} + +func (ti *TextInput) executeCompletion() { + if len(ti.completions) > 0 { + ti.Set(ti.prefix + ti.completions[ti.completeIndex].Value + ti.StringRight()) + } +} + +func (ti *TextInput) invalidateCompletions() { + ti.completions = nil +} + +func (ti *TextInput) onChange() { + ti.updateCompletions() + for _, change := range ti.change { + change(ti) + } +} + +func (ti *TextInput) onFocusLost() { + for _, focusLost := range ti.focusLost { + focusLost(ti) + } +} + +func (ti *TextInput) updateCompletions() { + if ti.tabcomplete == nil { + // no completer + return + } + if ti.completeMinChars == config.MANUAL_COMPLETE { + // only manually triggered completion + return + } + if ti.completeDebouncer == nil { + ti.completeDebouncer = time.AfterFunc(ti.completeDelay, func() { + defer log.PanicHandler() + ti.Lock() + if len(ti.StringLeft()) >= ti.completeMinChars { + ti.showCompletions(false) + } + ti.Unlock() + }) + } else { + ti.completeDebouncer.Stop() + ti.completeDebouncer.Reset(ti.completeDelay) + } +} + +func (ti *TextInput) showCompletions(explicit bool) { + if ti.tabcomplete == nil { + // no completer + return + } + if ti.tabcompleteCancel != nil { + // Cancel any inflight completions we currently have + ti.tabcompleteCancel() + } + ctx, cancel := context.WithCancel(context.Background()) + ti.tabcompleteCancel = cancel + go func() { + defer log.PanicHandler() + matches, prefix := ti.tabcomplete(ctx, ti.StringLeft()) + select { + case <-ctx.Done(): + return + default: + ti.Lock() + defer ti.Unlock() + ti.completions = matches + ti.prefix = prefix + if explicit && len(ti.completions) == 1 { + // automatically accept if there is only one choice + ti.completeIndex = 0 + ti.executeCompletion() + ti.invalidateCompletions() + } else { + ti.completeIndex = -1 + } + Invalidate() + } + }() +} + +func (ti *TextInput) OnChange(onChange func(ti *TextInput)) { + ti.change = append(ti.change, onChange) +} + +func (ti *TextInput) OnFocusLost(onFocusLost func(ti *TextInput)) { + ti.focusLost = append(ti.focusLost, onFocusLost) +} + +func (ti *TextInput) Event(event vaxis.Event) bool { + ti.Lock() + defer ti.Unlock() + if key, ok := event.(vaxis.Key); ok { + c := ti.completeKey + if c != nil && key.Matches(c.Key, c.Modifiers) { + ti.showCompletions(true) + return true + } + + ti.invalidateCompletions() + + switch { + case key.Matches(vaxis.KeyBackspace): + ti.backspace() + case key.Matches('d', vaxis.ModCtrl), key.Matches(vaxis.KeyDelete): + ti.deleteChar() + case key.Matches('b', vaxis.ModCtrl), key.Matches(vaxis.KeyLeft): + if ti.index > 0 { + ti.index-- + ti.ensureScroll() + ti.Invalidate() + } + case key.Matches('f', vaxis.ModCtrl), key.Matches(vaxis.KeyRight): + if ti.index < len(ti.text) { + ti.index++ + ti.ensureScroll() + ti.Invalidate() + } + case key.Matches('a', vaxis.ModCtrl), key.Matches(vaxis.KeyHome): + ti.index = 0 + ti.ensureScroll() + ti.Invalidate() + case key.Matches('e', vaxis.ModCtrl), key.Matches(vaxis.KeyEnd): + ti.index = len(ti.text) + ti.ensureScroll() + ti.Invalidate() + case key.Matches('k', vaxis.ModCtrl): + ti.deleteLineForward() + case key.Matches('w', vaxis.ModCtrl): + ti.deleteWord() + case key.Matches('u', vaxis.ModCtrl): + ti.deleteLineBackward() + case key.Matches(vaxis.KeyEsc): + ti.Invalidate() + case key.Text != "": + chars := vaxis.Characters(key.Text) + for _, ch := range chars { + ti.insert(ch) + } + } + } + return true +} + +type completions struct { + ti *TextInput + valWidth int + descWidth int +} + +func unquote(s string) string { + if strings.HasPrefix(s, "'") && strings.HasSuffix(s, "'") { + s = strings.ReplaceAll(s[1:len(s)-1], `'"'"'`, "'") + } + return s +} + +func (c *completions) Draw(ctx *Context) { + bg := c.ti.uiConfig.GetStyle(config.STYLE_COMPLETION_DEFAULT) + bgDesc := c.ti.uiConfig.GetStyle(config.STYLE_COMPLETION_DESCRIPTION) + gutter := c.ti.uiConfig.GetStyle(config.STYLE_COMPLETION_GUTTER) + pill := c.ti.uiConfig.GetStyle(config.STYLE_COMPLETION_PILL) + sel := c.ti.uiConfig.GetStyleSelected(config.STYLE_COMPLETION_DEFAULT) + selDesc := c.ti.uiConfig.GetStyleSelected(config.STYLE_COMPLETION_DESCRIPTION) + + ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', bg) + + numVisible := ctx.Height() + startIdx := 0 + if len(c.ti.completions) > numVisible && c.index()+1 > numVisible { + startIdx = c.index() - (numVisible - 1) + } + endIdx := startIdx + numVisible - 1 + + for idx, opt := range c.ti.completions { + if idx < startIdx { + continue + } + if idx > endIdx { + continue + } + val := runewidth.FillRight(unquote(opt.Value), c.valWidth) + desc := opt.Description + if desc != "" { + if runewidth.StringWidth(desc) > c.descWidth { + desc = runewidth.Truncate(desc, c.descWidth, "…") + } + desc = " " + runewidth.FillRight("("+desc+")", c.descWidth+2) + } + if c.index() == idx { + n := ctx.Printf(0, idx-startIdx, sel, " %s", val) + ctx.Printf(n, idx-startIdx, selDesc, "%s ", desc) + } else { + n := ctx.Printf(0, idx-startIdx, bg, " %s", val) + ctx.Printf(n, idx-startIdx, bgDesc, "%s ", desc) + } + } + + percentVisible := float64(numVisible) / float64(len(c.ti.completions)) + if percentVisible >= 1.0 { + return + } + + // gutter + ctx.Fill(ctx.Width()-1, 0, 1, ctx.Height(), ' ', gutter) + + pillSize := int(math.Ceil(float64(ctx.Height()) * percentVisible)) + percentScrolled := float64(startIdx) / float64(len(c.ti.completions)) + pillOffset := int(math.Floor(float64(ctx.Height()) * percentScrolled)) + ctx.Fill(ctx.Width()-1, pillOffset, 1, pillSize, ' ', pill) +} + +func (c *completions) index() int { + return c.ti.completeIndex +} + +func (c *completions) next() { + index := c.index() + index++ + if index >= len(c.ti.completions) { + index = -1 + } + c.ti.completeIndex = index + Invalidate() +} + +func (c *completions) prev() { + index := c.index() + index-- + if index < -1 { + index = len(c.ti.completions) - 1 + } + c.ti.completeIndex = index + Invalidate() +} + +func (c *completions) exec() { + c.ti.executeCompletion() + c.ti.invalidateCompletions() + Invalidate() +} + +func (c *completions) Event(e vaxis.Event) bool { + if e, ok := e.(vaxis.Key); ok { + k := c.ti.completeKey + if k != nil && e.Matches(k.Key, k.Modifiers) { + if len(c.ti.completions) == 1 { + c.ti.completeIndex = 0 + c.exec() + } else { + stem := findStem(c.ti.completions) + if c.needsStem(stem) { + c.stem(stem) + } + c.next() + } + return true + } + + switch { + case e.Matches('n', vaxis.ModCtrl), e.Matches(vaxis.KeyDown): + c.next() + return true + case e.Matches(vaxis.KeyTab, vaxis.ModShift), + e.Matches('p', vaxis.ModCtrl), + e.Matches(vaxis.KeyUp): + c.prev() + return true + case e.Matches(vaxis.KeyEnter): + if c.index() >= 0 { + c.exec() + return true + } + } + } + return false +} + +func (c *completions) needsStem(stem string) bool { + if stem == "" || c.index() >= 0 { + return false + } + if len(stem)+len(c.ti.prefix) > len(c.ti.StringLeft()) { + return true + } + return false +} + +func (c *completions) stem(stem string) { + c.ti.Set(c.ti.prefix + stem + c.ti.StringRight()) + c.ti.index = len(vaxis.Characters(c.ti.prefix + stem)) +} + +func findStem(words []opt.Completion) string { + if len(words) == 0 { + return "" + } + if len(words) == 1 { + return words[0].Value + } + var stem string + stemLen := 1 + firstWord := []rune(words[0].Value) + for { + if len(firstWord) < stemLen { + return stem + } + var r rune = firstWord[stemLen-1] + for _, word := range words[1:] { + runes := []rune(word.Value) + if len(runes) < stemLen { + return stem + } + if runes[stemLen-1] != r { + return stem + } + } + stem += string(r) + stemLen++ + } +} + +func (c *completions) Focus(_ bool) {} + +func (c *completions) Invalidate() {} diff --git a/lib/ui/textinput_test.go b/lib/ui/textinput_test.go new file mode 100644 index 0000000..22bf43a --- /dev/null +++ b/lib/ui/textinput_test.go @@ -0,0 +1,72 @@ +package ui + +import "testing" + +func TestDeleteWord(t *testing.T) { + tests := []struct { + name string + text string + expected string + }{ + { + name: "hello-world", + text: "hello world", + expected: "hello ", + }, + { + name: "empty", + text: "", + expected: "", + }, + { + name: "quoted", + text: `"hello"`, + expected: `"hello`, + }, + { + name: "hello-and-space", + text: "hello ", + expected: "", + }, + { + name: "space-and-hello", + text: " hello", + expected: " ", + }, + { + name: "only-quote", + text: `"`, + expected: "", + }, + { + name: "only-space", + text: " ", + expected: "", + }, + { + name: "space-and-quoted", + text: " 'hello", + expected: " '", + }, + { + name: "paths", + text: "foo/bar/baz", + expected: "foo/bar/", + }, + { + name: "space-and-paths", + text: " /foo", + expected: " /", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + textinput := NewTextInput(test.text, nil) + textinput.deleteWord() + if charactersToString(textinput.text) != test.expected { + t.Errorf("word was deleted incorrectly: got %s but expected %s", charactersToString(textinput.text), test.expected) + } + }) + } +} diff --git a/lib/ui/ui.go b/lib/ui/ui.go new file mode 100644 index 0000000..0b23300 --- /dev/null +++ b/lib/ui/ui.go @@ -0,0 +1,186 @@ +package ui + +import ( + "os" + "os/signal" + "sync/atomic" + "syscall" + + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rockorager/vaxis" +) + +// Use unbuffered channels (always blocking unless somebody can read +// immediately) We are merely using this as a proxy to the internal vaxis event +// channel. +var Events = make(chan vaxis.Event) + +var Quit = make(chan struct{}) + +var Callbacks = make(chan func(), 50) + +// QueueFunc queues a function to be called in the main goroutine. This can be +// used to prevent race conditions from delayed functions +func QueueFunc(fn func()) { + Callbacks <- fn +} + +// Use a buffered channel of size 1 to avoid blocking callers of Invalidate() +var Redraw = make(chan bool, 1) + +// Invalidate marks the entire UI as invalid and request a redraw as soon as +// possible. Invalidate can be called from any goroutine and will never block. +func Invalidate() { + if atomic.SwapUint32(&state.dirty, 1) != 1 { + Redraw <- true + } +} + +var state struct { + content DrawableInteractive + ctx *Context + vx *vaxis.Vaxis + popover *Popover + dirty uint32 // == 1 if render has been queued in Redraw channel + // == 1 if suspend is pending + suspending uint32 + refresh uint32 // == 1 if a refresh has been queued +} + +func Initialize(content DrawableInteractive) error { + opts := vaxis.Options{ + DisableMouse: !config.Ui.MouseEnabled, + CSIuBitMask: vaxis.CSIuDisambiguate, + WithTTY: "/dev/tty", + } + vx, err := vaxis.New(opts) + if err != nil { + return err + } + + vx.Window().Clear() + vx.HideCursor() + + state.content = content + state.vx = vx + state.ctx = NewContext(state.vx, onPopover) + vx.SetTitle("aerc") + + Invalidate() + if beeper, ok := content.(DrawableInteractiveBeeper); ok { + beeper.OnBeep(vx.Bell) + } + content.Focus(true) + + go func() { + defer log.PanicHandler() + for event := range vx.Events() { + Events <- event + } + }() + + return nil +} + +func onPopover(p *Popover) { + state.popover = p +} + +func Exit() { + close(Quit) +} + +var SuspendQueue = make(chan bool, 1) + +func QueueSuspend() { + if atomic.SwapUint32(&state.suspending, 1) != 1 { + SuspendQueue <- true + } +} + +// SuspendScreen should be called from the main thread. +func SuspendScreen() { + _ = state.vx.Suspend() +} + +func ResumeScreen() { + err := state.vx.Resume() + if err != nil { + log.Errorf("ui: cannot resume after suspend: %v", err) + } + Invalidate() +} + +func Suspend() error { + var err error + if atomic.SwapUint32(&state.suspending, 0) != 0 { + err = state.vx.Suspend() + if err == nil { + sigcont := make(chan os.Signal, 1) + signal.Notify(sigcont, syscall.SIGCONT) + err = syscall.Kill(0, syscall.SIGTSTP) + if err == nil { + <-sigcont + } + signal.Reset(syscall.SIGCONT) + err = state.vx.Resume() + state.content.Draw(state.ctx) + state.vx.Render() + } + } + return err +} + +func Close() { + state.vx.Close() +} + +func QueueRefresh() { + if atomic.SwapUint32(&state.refresh, 1) != 1 { + Invalidate() + } +} + +func Render() { + if atomic.SwapUint32(&state.dirty, 0) != 0 { + state.vx.Window().Clear() + // reset popover for the next Draw + state.popover = nil + state.vx.HideCursor() + state.content.Draw(state.ctx) + if state.popover != nil { + // if the Draw resulted in a popover, draw it + state.popover.Draw(state.ctx) + } + switch atomic.SwapUint32(&state.refresh, 0) { + case 0: + state.vx.Render() + case 1: + state.vx.Refresh() + } + } +} + +func HandleEvent(event vaxis.Event) { + switch event := event.(type) { + case vaxis.Resize: + state.ctx = NewContext(state.vx, onPopover) + Invalidate() + case vaxis.Redraw: + Invalidate() + default: + // We never care about num or caps lock. Remove them so it + // doesn't interfere with key matching + if key, ok := event.(vaxis.Key); ok { + key.Modifiers &^= vaxis.ModCapsLock + key.Modifiers &^= vaxis.ModNumLock + event = key + } + // if we have a popover, and it can handle the event, it does so + if state.popover == nil || !state.popover.Event(event) { + // otherwise, we send the event to the main content + state.content.Event(event) + } + } +} diff --git a/lib/watchers/fsevents.go b/lib/watchers/fsevents.go new file mode 100644 index 0000000..a273dcb --- /dev/null +++ b/lib/watchers/fsevents.go @@ -0,0 +1,82 @@ +//go:build darwin +// +build darwin + +package watchers + +import ( + "time" + + "git.sr.ht/~rjarry/aerc/lib/log" + "github.com/fsnotify/fsevents" +) + +func init() { + RegisterWatcherFactory(newDarwinWatcher) +} + +type darwinWatcher struct { + ch chan *FSEvent + w *fsevents.EventStream + watcherCh chan []fsevents.Event +} + +func newDarwinWatcher() (FSWatcher, error) { + watcher := &darwinWatcher{ + watcherCh: make(chan []fsevents.Event), + ch: make(chan *FSEvent), + w: &fsevents.EventStream{ + Flags: fsevents.FileEvents | fsevents.WatchRoot, + Latency: 500 * time.Millisecond, + }, + } + return watcher, nil +} + +func (w *darwinWatcher) watch() { + defer log.PanicHandler() + for events := range w.w.Events { + for _, ev := range events { + switch { + case ev.Flags&fsevents.ItemCreated > 0: + w.ch <- &FSEvent{ + Operation: FSCreate, + Path: ev.Path, + } + case ev.Flags&fsevents.ItemRenamed > 0: + w.ch <- &FSEvent{ + Operation: FSRename, + Path: ev.Path, + } + case ev.Flags&fsevents.ItemRemoved > 0: + w.ch <- &FSEvent{ + Operation: FSRemove, + Path: ev.Path, + } + } + } + } +} + +func (w *darwinWatcher) Configure(root string) error { + dev, err := fsevents.DeviceForPath(root) + if err != nil { + return err + } + w.w.Device = dev + w.w.Paths = []string{root} + w.w.Start() + go w.watch() + return nil +} + +func (w *darwinWatcher) Events() chan *FSEvent { + return w.ch +} + +func (w *darwinWatcher) Add(p string) error { + return nil +} + +func (w *darwinWatcher) Remove(p string) error { + return nil +} diff --git a/lib/watchers/inotify.go b/lib/watchers/inotify.go new file mode 100644 index 0000000..dd34fc4 --- /dev/null +++ b/lib/watchers/inotify.go @@ -0,0 +1,74 @@ +//go:build !darwin +// +build !darwin + +package watchers + +import ( + "git.sr.ht/~rjarry/aerc/lib/log" + "github.com/fsnotify/fsnotify" +) + +func init() { + RegisterWatcherFactory(newInotifyWatcher) +} + +type inotifyWatcher struct { + w *fsnotify.Watcher + ch chan *FSEvent +} + +func newInotifyWatcher() (FSWatcher, error) { + watcher := &inotifyWatcher{ + ch: make(chan *FSEvent), + } + w, err := fsnotify.NewWatcher() + if err != nil { + return nil, err + } + watcher.w = w + + go watcher.watch() + return watcher, nil +} + +func (w *inotifyWatcher) watch() { + defer log.PanicHandler() + for ev := range w.w.Events { + // we only care about files being created, removed or renamed + switch ev.Op { + case fsnotify.Create: + w.ch <- &FSEvent{ + Operation: FSCreate, + Path: ev.Name, + } + case fsnotify.Remove: + w.ch <- &FSEvent{ + Operation: FSRemove, + Path: ev.Name, + } + case fsnotify.Rename: + w.ch <- &FSEvent{ + Operation: FSRename, + Path: ev.Name, + } + default: + continue + } + } +} + +func (w *inotifyWatcher) Configure(root string) error { + return w.w.Add(root) +} + +func (w *inotifyWatcher) Events() chan *FSEvent { + return w.ch +} + +func (w *inotifyWatcher) Add(p string) error { + return w.w.Add(p) +} + +func (w *inotifyWatcher) Remove(p string) error { + return w.w.Remove(p) +} diff --git a/lib/watchers/watchers.go b/lib/watchers/watchers.go new file mode 100644 index 0000000..06ef985 --- /dev/null +++ b/lib/watchers/watchers.go @@ -0,0 +1,44 @@ +package watchers + +import ( + "fmt" + "runtime" +) + +// FSWatcher is a file system watcher +type FSWatcher interface { + Configure(string) error + Events() chan *FSEvent + // Adds a directory or file to the watcher + Add(string) error + // Removes a directory or file from the watcher + Remove(string) error +} + +type FSOperation int + +const ( + FSCreate FSOperation = iota + FSRemove + FSRename +) + +type FSEvent struct { + Operation FSOperation + Path string +} + +type WatcherFactoryFunc func() (FSWatcher, error) + +var watcherFactory WatcherFactoryFunc + +func RegisterWatcherFactory(fn WatcherFactoryFunc) { + watcherFactory = fn +} + +func NewWatcher() (FSWatcher, error) { + if watcherFactory == nil { + return nil, fmt.Errorf("Unsupported OS: %s", runtime.GOOS) + } + return watcherFactory() +} diff --git a/lib/xdg/home.go b/lib/xdg/home.go new file mode 100644 index 0000000..39894cf --- /dev/null +++ b/lib/xdg/home.go @@ -0,0 +1,47 @@ +package xdg + +import ( + "os" + "os/user" + "path" + "strings" + + "git.sr.ht/~rjarry/aerc/lib/log" +) + +// assign to a local var to allow mocking in unit tests +var currentUser = user.Current + +// Get the current user home directory (first from the $HOME env var and +// fallback on calling getpwuid_r() from libc if $HOME is unset). +func HomeDir() string { + home, err := os.UserHomeDir() + if err != nil { + u, e := currentUser() + if e == nil { + home = u.HomeDir + } else { + log.Errorf("HomeDir: %s (while handling %s)", e, err) + } + } + return home +} + +// Replace ~ with the current user's home dir +func ExpandHome(fragments ...string) string { + home := HomeDir() + res := path.Join(fragments...) + if strings.HasPrefix(res, "~/") || res == "~" { + res = home + strings.TrimPrefix(res, "~") + } + return res +} + +// Replace $HOME with ~ (inverse function of ExpandHome) +func TildeHome(path string) string { + home := HomeDir() + if strings.HasPrefix(path, home+"/") || path == home { + path = "~" + strings.TrimPrefix(path, home) + } + return path +} diff --git a/lib/xdg/home_test.go b/lib/xdg/home_test.go new file mode 100644 index 0000000..673e35b --- /dev/null +++ b/lib/xdg/home_test.go @@ -0,0 +1,88 @@ +package xdg + +import ( + "errors" + "os/user" + "testing" +) + +func TestHomeDir(t *testing.T) { + t.Run("from env", func(t *testing.T) { + t.Setenv("HOME", "/home/user") + home := HomeDir() + if home != "/home/user" { + t.Errorf(`got %q expected "/home/user"`, home) + } + }) + t.Run("from getpwuid_r", func(t *testing.T) { + t.Setenv("HOME", "") + orig := currentUser + currentUser = func() (*user.User, error) { + return &user.User{HomeDir: "/home/user"}, nil + } + home := HomeDir() + currentUser = orig + if home != "/home/user" { + t.Errorf(`got %q expected "/home/user"`, home) + } + }) + t.Run("failure", func(t *testing.T) { + t.Setenv("HOME", "") + orig := currentUser + currentUser = func() (*user.User, error) { + return nil, errors.New("no such user") + } + home := HomeDir() + currentUser = orig + if home != "" { + t.Errorf(`got %q expected ""`, home) + } + }) +} + +func TestExpandHome(t *testing.T) { + t.Setenv("HOME", "/home/user") + vectors := []struct { + args []string + expected string + }{ + {args: []string{"foo"}, expected: "foo"}, + {args: []string{"foo", "bar"}, expected: "foo/bar"}, + {args: []string{"/foobar/baz"}, expected: "/foobar/baz"}, + {args: []string{"~/foobar/baz"}, expected: "/home/user/foobar/baz"}, + {args: []string{}, expected: ""}, + {args: []string{"~"}, expected: "/home/user"}, + } + for _, vec := range vectors { + t.Run(vec.expected, func(t *testing.T) { + res := ExpandHome(vec.args...) + if res != vec.expected { + t.Errorf("got %q expected %q", res, vec.expected) + } + }) + } +} + +func TestTildeHome(t *testing.T) { + t.Setenv("HOME", "/home/user") + vectors := []struct { + arg string + expected string + }{ + {arg: "foo", expected: "foo"}, + {arg: "foo/bar", expected: "foo/bar"}, + {arg: "/foobar/baz", expected: "/foobar/baz"}, + {arg: "/home/user/foobar/baz", expected: "~/foobar/baz"}, + {arg: "", expected: ""}, + {arg: "/home/user", expected: "~"}, + {arg: "/home/user2/foobar/baz", expected: "/home/user2/foobar/baz"}, + } + for _, vec := range vectors { + t.Run(vec.expected, func(t *testing.T) { + res := TildeHome(vec.arg) + if res != vec.expected { + t.Errorf("got %q expected %q", res, vec.expected) + } + }) + } +} diff --git a/lib/xdg/xdg.go b/lib/xdg/xdg.go new file mode 100644 index 0000000..0c578fa --- /dev/null +++ b/lib/xdg/xdg.go @@ -0,0 +1,115 @@ +package xdg + +import ( + "os" + "path/filepath" + "runtime" + "strconv" +) + +// Return a path relative to the user home cache dir +func CachePath(paths ...string) string { + res := filepath.Join(paths...) + if !filepath.IsAbs(res) { + var cache string + if runtime.GOOS == "darwin" { + // preserve backward compat with github.com/kyoh86/xdg + cache = os.Getenv("XDG_CACHE_HOME") + } + if cache == "" { + var err error + cache, err = os.UserCacheDir() + if err != nil { + cache = ExpandHome("~/.cache") + } + } + res = filepath.Join(cache, res) + } + return res +} + +// Return a path relative to the user home config dir +func ConfigPath(paths ...string) string { + res := filepath.Join(paths...) + if !filepath.IsAbs(res) { + var config string + if runtime.GOOS == "darwin" { + // preserve backward compat with github.com/kyoh86/xdg + config = os.Getenv("XDG_CONFIG_HOME") + if config == "" { + config = ExpandHome("~/Library/Preferences") + } + } else { + var err error + config, err = os.UserConfigDir() + if err != nil { + config = ExpandHome("~/.config") + } + } + res = filepath.Join(config, res) + } + return res +} + +// Return a path relative to the user data home dir +func DataPath(paths ...string) string { + res := filepath.Join(paths...) + if !filepath.IsAbs(res) { + data := os.Getenv("XDG_DATA_HOME") + // preserve backward compat with github.com/kyoh86/xdg + if data == "" && runtime.GOOS == "darwin" { + data = ExpandHome("~/Library/Application Support") + } else if data == "" { + data = ExpandHome("~/.local/share") + } + res = filepath.Join(data, res) + } + return res +} + +// Return a path relative to the user state home dir +func StatePath(paths ...string) string { + res := filepath.Join(paths...) + if !filepath.IsAbs(res) { + data := os.Getenv("XDG_STATE_HOME") + if data == "" { + data = ExpandHome("~/.local/state") + } + res = filepath.Join(data, res) + } + return res +} + +// ugly: there's no other way to allow mocking a function in go... +var userRuntimePath = func() string { + uid := strconv.Itoa(os.Getuid()) + path := filepath.Join("/run/user", uid) + fi, err := os.Stat(path) + if err != nil || !fi.Mode().IsDir() { + // OpenRC does not create /run/user. TMUX and Neovim + // create /tmp/$program-$uid instead. Mimic that. + path = filepath.Join(os.TempDir(), "aerc-"+uid) + err = os.MkdirAll(path, 0o700) + if err != nil { + // Fallback to /tmp if all else fails. + path = os.TempDir() + } + } + return path +} + +// Return a path relative to the user runtime dir +func RuntimePath(paths ...string) string { + res := filepath.Join(paths...) + if !filepath.IsAbs(res) { + run := os.Getenv("XDG_RUNTIME_DIR") + // preserve backward compat with github.com/kyoh86/xdg + if run == "" && runtime.GOOS == "darwin" { + run = ExpandHome("~/Library/Application Support") + } else if run == "" { + run = userRuntimePath() + } + res = filepath.Join(run, res) + } + return res +} diff --git a/lib/xdg/xdg_test.go b/lib/xdg/xdg_test.go new file mode 100644 index 0000000..8ab68e0 --- /dev/null +++ b/lib/xdg/xdg_test.go @@ -0,0 +1,183 @@ +package xdg + +import ( + "runtime" + "testing" +) + +func TestCachePath(t *testing.T) { + t.Setenv("HOME", "/home/user") + vectors := []struct { + args []string + env map[string]string + expected map[string]string + }{ + { + args: []string{"aerc", "foo", "history"}, + env: map[string]string{"XDG_CACHE_HOME": ""}, + expected: map[string]string{ + "": "/home/user/.cache/aerc/foo/history", + "darwin": "/home/user/Library/Caches/aerc/foo/history", + }, + }, + { + args: []string{"aerc", "foo/zuul"}, + env: map[string]string{"XDG_CACHE_HOME": "/home/x/.cache"}, + expected: map[string]string{"": "/home/x/.cache/aerc/foo/zuul"}, + }, + { + args: []string{}, + env: map[string]string{"XDG_CACHE_HOME": "/blah"}, + expected: map[string]string{"": "/blah"}, + }, + } + for _, vec := range vectors { + expected, found := vec.expected[runtime.GOOS] + if !found { + expected = vec.expected[""] + } + t.Run(expected, func(t *testing.T) { + for key, value := range vec.env { + t.Setenv(key, value) + } + res := CachePath(vec.args...) + if res != expected { + t.Errorf("got %q expected %q", res, expected) + } + }) + } +} + +func TestConfigPath(t *testing.T) { + t.Setenv("HOME", "/home/user") + vectors := []struct { + args []string + env map[string]string + expected map[string]string + }{ + { + args: []string{"aerc", "accounts.conf"}, + env: map[string]string{"XDG_CONFIG_HOME": ""}, + expected: map[string]string{ + "": "/home/user/.config/aerc/accounts.conf", + "darwin": "/home/user/Library/Preferences/aerc/accounts.conf", + }, + }, + { + args: []string{"aerc", "accounts.conf"}, + env: map[string]string{"XDG_CONFIG_HOME": "/users/x/.config"}, + expected: map[string]string{"": "/users/x/.config/aerc/accounts.conf"}, + }, + { + args: []string{}, + env: map[string]string{"XDG_CONFIG_HOME": "/blah"}, + expected: map[string]string{"": "/blah"}, + }, + } + for _, vec := range vectors { + expected, found := vec.expected[runtime.GOOS] + if !found { + expected = vec.expected[""] + } + t.Run(expected, func(t *testing.T) { + for key, value := range vec.env { + t.Setenv(key, value) + } + res := ConfigPath(vec.args...) + if res != expected { + t.Errorf("got %q expected %q", res, expected) + } + }) + } +} + +func TestDataPath(t *testing.T) { + t.Setenv("HOME", "/home/user") + vectors := []struct { + args []string + env map[string]string + expected map[string]string + }{ + { + args: []string{"aerc", "templates"}, + env: map[string]string{"XDG_DATA_HOME": ""}, + expected: map[string]string{ + "": "/home/user/.local/share/aerc/templates", + "darwin": "/home/user/Library/Application Support/aerc/templates", + }, + }, + { + args: []string{"aerc", "templates"}, + env: map[string]string{"XDG_DATA_HOME": "/users/x/.local/share"}, + expected: map[string]string{"": "/users/x/.local/share/aerc/templates"}, + }, + { + args: []string{}, + env: map[string]string{"XDG_DATA_HOME": "/blah"}, + expected: map[string]string{"": "/blah"}, + }, + } + for _, vec := range vectors { + expected, found := vec.expected[runtime.GOOS] + if !found { + expected = vec.expected[""] + } + t.Run(expected, func(t *testing.T) { + for key, value := range vec.env { + t.Setenv(key, value) + } + res := DataPath(vec.args...) + if res != expected { + t.Errorf("got %q expected %q", res, expected) + } + }) + } +} + +func TestRuntimePath(t *testing.T) { + // poor man's function mocking + orig := userRuntimePath + userRuntimePath = func() string { return "/run/user/1000" } + defer func() { userRuntimePath = orig }() + t.Setenv("HOME", "/home/user") + + vectors := []struct { + args []string + env map[string]string + expected map[string]string + }{ + { + args: []string{"aerc.sock"}, + env: map[string]string{"XDG_RUNTIME_DIR": ""}, + expected: map[string]string{ + "": "/run/user/1000/aerc.sock", + "darwin": "/home/user/Library/Application Support/aerc.sock", + }, + }, + { + args: []string{"aerc.sock"}, + env: map[string]string{"XDG_RUNTIME_DIR": "/run/user/1234"}, + expected: map[string]string{"": "/run/user/1234/aerc.sock"}, + }, + { + args: []string{}, + env: map[string]string{"XDG_RUNTIME_DIR": "/blah"}, + expected: map[string]string{"": "/blah"}, + }, + } + for _, vec := range vectors { + expected, found := vec.expected[runtime.GOOS] + if !found { + expected = vec.expected[""] + } + t.Run(expected, func(t *testing.T) { + for key, value := range vec.env { + t.Setenv(key, value) + } + res := RuntimePath(vec.args...) + if res != expected { + t.Errorf("got %q expected %q", res, expected) + } + }) + } +} diff --git a/lib/xoauth2.go b/lib/xoauth2.go new file mode 100644 index 0000000..65f914d --- /dev/null +++ b/lib/xoauth2.go @@ -0,0 +1,123 @@ +// +// This code is derived from the go-sasl library. +// +// Copyright (c) 2016 emersion +// Copyright (c) 2022, Oracle and/or its affiliates. +// +// SPDX-License-Identifier: MIT + +package lib + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "git.sr.ht/~rjarry/aerc/lib/xdg" + "github.com/emersion/go-imap/client" + "github.com/emersion/go-sasl" + "golang.org/x/oauth2" +) + +// An XOAUTH2 error. +type Xoauth2Error struct { + Status string `json:"status"` + Schemes string `json:"schemes"` + Scope string `json:"scope"` +} + +// Implements error. +func (err *Xoauth2Error) Error() string { + return fmt.Sprintf("XOAUTH2 authentication error (%v)", err.Status) +} + +type xoauth2Client struct { + Username string + Token string +} + +func (a *xoauth2Client) Start() (mech string, ir []byte, err error) { + mech = "XOAUTH2" + ir = []byte("user=" + a.Username + "\x01auth=Bearer " + a.Token + "\x01\x01") + return +} + +func (a *xoauth2Client) Next(challenge []byte) ([]byte, error) { + // Server sent an error response + xoauth2Err := &Xoauth2Error{} + if err := json.Unmarshal(challenge, xoauth2Err); err != nil { + return nil, err + } else { + return nil, xoauth2Err + } +} + +// An implementation of the XOAUTH2 authentication mechanism, as +// described in https://developers.google.com/gmail/xoauth2_protocol. +func NewXoauth2Client(username, token string) sasl.Client { + return &xoauth2Client{username, token} +} + +type Xoauth2 struct { + OAuth2 *oauth2.Config + Enabled bool +} + +func (c *Xoauth2) ExchangeRefreshToken(refreshToken string) (*oauth2.Token, error) { + token := new(oauth2.Token) + token.RefreshToken = refreshToken + token.TokenType = "Bearer" + return c.OAuth2.TokenSource(context.TODO(), token).Token() +} + +func SaveRefreshToken(refreshToken string, acct string) error { + p := xdg.CachePath("aerc", acct+"-xoauth2.token") + _ = os.MkdirAll(xdg.CachePath("aerc"), 0o700) + + return os.WriteFile( + p, + []byte(refreshToken), + 0o600, + ) +} + +func GetRefreshToken(acct string) ([]byte, error) { + p := xdg.CachePath("aerc", acct+"-xoauth2.token") + return os.ReadFile(p) +} + +func (c *Xoauth2) Authenticate( + username string, + password string, + account string, + client *client.Client, +) error { + if ok, err := client.SupportAuth("XOAUTH2"); err != nil || !ok { + return fmt.Errorf("Xoauth2 not supported %w", err) + } + + if c.OAuth2.Endpoint.TokenURL != "" { + usedCache := false + if r, err := GetRefreshToken(account); err == nil && len(r) > 0 { + password = string(r) + usedCache = true + } + + token, err := c.ExchangeRefreshToken(password) + if err != nil { + if usedCache { + return fmt.Errorf("try removing cached refresh token. %w", err) + } + return err + } + password = token.AccessToken + if err := SaveRefreshToken(token.RefreshToken, account); err != nil { + return err + } + } + + saslClient := NewXoauth2Client(username, password) + + return client.Authenticate(saslClient) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..5f5e513 --- /dev/null +++ b/main.go @@ -0,0 +1,324 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + "runtime" + "sort" + "strings" + "sync" + "time" + + "git.sr.ht/~rjarry/go-opt/v2" + + "git.sr.ht/~rjarry/aerc/app" + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/lib" + "git.sr.ht/~rjarry/aerc/lib/crypto" + "git.sr.ht/~rjarry/aerc/lib/hooks" + "git.sr.ht/~rjarry/aerc/lib/ipc" + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/pinentry" + "git.sr.ht/~rjarry/aerc/lib/templates" + "git.sr.ht/~rjarry/aerc/lib/ui" + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/types" + + _ "git.sr.ht/~rjarry/aerc/commands/account" + _ "git.sr.ht/~rjarry/aerc/commands/compose" + _ "git.sr.ht/~rjarry/aerc/commands/msg" + _ "git.sr.ht/~rjarry/aerc/commands/msgview" + _ "git.sr.ht/~rjarry/aerc/commands/patch" +) + +func execCommand( + cmdline string, + acct *config.AccountConfig, msg *models.MessageInfo, +) error { + cmdline, cmd, err := commands.ResolveCommand(cmdline, acct, msg) + if err != nil { + return err + } + err = commands.ExecuteCommand(cmd, cmdline) + if errors.As(err, new(commands.ErrorExit)) { + ui.Exit() + return nil + } + return err +} + +func getCompletions(ctx context.Context, cmdline string) ([]opt.Completion, string) { + // complete template terms + if options, prefix, ok := commands.GetTemplateCompletion(cmdline); ok { + sort.Strings(options) + completions := make([]opt.Completion, 0, len(options)) + for _, o := range options { + completions = append(completions, opt.Completion{ + Value: o, + Description: "Template", + }) + } + return completions, prefix + } + + args := opt.LexArgs(cmdline) + + if args.Count() < 2 && args.TrailingSpace() == "" { + // complete command names + var completions []opt.Completion + for _, cmd := range commands.ActiveCommands() { + for _, alias := range cmd.Aliases() { + if strings.HasPrefix(alias, cmdline) { + completions = append(completions, opt.Completion{ + Value: alias + " ", + Description: cmd.Description(), + }) + } + } + } + sort.Slice(completions, func(i, j int) bool { + return completions[i].Value < completions[j].Value + }) + return completions, "" + } + + // complete command arguments + _, cmd, err := commands.ExpandAbbreviations(args.Arg(0)) + if err != nil { + return nil, cmdline + } + return commands.GetCompletions(cmd, args) +} + +// set at build time +var ( + Version string + Date string +) + +func buildInfo() string { + info := Version + if soVersion, hasNotmuch := lib.NotmuchVersion(); hasNotmuch { + info += fmt.Sprintf(" +notmuch-%s", soVersion) + } + info += fmt.Sprintf(" (%s %s %s %s)", + runtime.Version(), runtime.GOARCH, runtime.GOOS, Date) + return info +} + +type Opts struct { + Help bool `opt:"-h,--help" action:"ShowHelp"` + Version bool `opt:"-v,--version" action:"ShowVersion"` + Accounts []string `opt:"-a,--account" action:"ParseAccounts" metavar:"<name>"` + ConfAerc string `opt:"-C,--aerc-conf" metavar:"<file>"` + ConfAccounts string `opt:"-A,--accounts-conf" metavar:"<file>"` + ConfBinds string `opt:"-B,--binds-conf" metavar:"<file>"` + NoIPC bool `opt:"-I,--no-ipc"` + Command []string `opt:"..." required:"false" metavar:"mailto:<address> | mbox:<file> | :<command...>"` +} + +func (o *Opts) ShowHelp(arg string) error { + fmt.Println("Usage: " + opt.NewCmdSpec(os.Args[0], o).Usage()) + fmt.Print(` +Aerc is an email client for your terminal. + +Options: + + -h, --help Show this help message and exit. + -v, --version Print version information. + -a <name>, --account <name> + Load only the named account, as opposed to all configured + accounts. It can also be a comma separated list of names. + This option may be specified multiple times. The account + order will be preserved. + -C <file>, --aerc-conf <file> + Path to configuration file to be used instead of the default. + -A <file>, --accounts-conf <file> + Path to configuration file to be used instead of the default. + -B <file>, --binds-conf <file> + Path to configuration file to be used instead of the default. + -I, --no-ipc Run any commands in this aerc instance, and don't create a + socket for other aerc instances to communicate with this one. + mailto:<address> Open the composer with the address(es) in the To field. + If aerc is already running, the composer is started in + this instance, otherwise aerc will be started. + mbox:<file> Open the specified mbox file as a virtual temporary account. + :<command...> Run an aerc command as you would in Ex-Mode. +`) + os.Exit(0) + return nil +} + +func (o *Opts) ShowVersion(arg string) error { + fmt.Println("aerc " + log.BuildInfo) + os.Exit(0) + return nil +} + +func (o *Opts) ParseAccounts(arg string) error { + o.Accounts = append(o.Accounts, strings.Split(arg, ",")...) + return nil +} + +func die(format string, args ...any) { + fmt.Fprintf(os.Stderr, "error: "+format+"\n", args...) + os.Exit(1) +} + +func main() { + defer log.PanicHandler() + log.BuildInfo = buildInfo() + + var opts Opts + + args := opt.QuoteArgs(os.Args...) + err := opt.ArgsToStruct(args, &opts) + if err != nil { + die("%s", err) + } + switch { + case len(opts.Command) == 0: + break + case strings.HasPrefix(opts.Command[0], ":"): + case strings.HasPrefix(opts.Command[0], "mailto:"): + case strings.HasPrefix(opts.Command[0], "mbox:"): + break + default: + die("unknown argument: %s", opts.Command[0]) + } + + err = config.LoadConfigFromFile( + nil, opts.Accounts, opts.ConfAerc, opts.ConfBinds, opts.ConfAccounts, + ) + if err != nil { + die("%s", err) + } + + noIPC := opts.NoIPC || config.General.DisableIPC + + if len(opts.Command) > 0 && !noIPC && + !(config.General.DisableIPCMailto && strings.HasPrefix(opts.Command[0], "mailto:")) && + !(config.General.DisableIPCMbox && strings.HasPrefix(opts.Command[0], "mbox:")) { + + response, err := ipc.ConnectAndExec(opts.Command) + if err == nil { + if response.Error != "" { + fmt.Printf("response: %s\n", response.Error) + } + return // other aerc instance takes over + } + // continue with setting up a new aerc instance and retry after init + } + + log.Infof("Starting up version %s", log.BuildInfo) + + deferLoop := make(chan struct{}) + + c := crypto.New() + err = c.Init() + if err != nil { + log.Warnf("failed to initialise crypto interface: %v", err) + } + defer c.Close() + + app.Init(c, execCommand, getCompletions, &commands.CmdHistory, deferLoop) + + err = ui.Initialize(app.Drawable()) + if err != nil { + panic(err) + } + defer ui.Close() + log.UICleanup = func() { + ui.Close() + } + close(deferLoop) + + config.EnablePinentry = pinentry.Enable + config.DisablePinentry = pinentry.Disable + config.SetPinentryEnv = pinentry.SetCmdEnv + + startup, startupDone := context.WithCancel(context.Background()) + + if !noIPC { + as, err := ipc.StartServer(app.IPCHandler(), startup) + if err != nil { + log.Warnf("Failed to start Unix server: %v", err) + } else { + defer as.Close() + } + } + + // set the aerc version so that we can use it in the template funcs + templates.SetVersion(Version) + templates.SetExecPath(config.SearchDirs) + + endStartup := func() { + startupDone() + if len(opts.Command) == 0 { + return + } + // Retry execution. Since IPC has already failed, we know no + // other aerc instance is running (or IPC was explicitly + // disabled); run the command directly. + err := app.Command(opts.Command) + if err != nil { + // no other aerc instance is running, so let + // this one stay running but show the error + errMsg := fmt.Sprintf("Startup command (%s) failed: %s\n", + strings.Join(opts.Command, " "), err) + log.Errorf(errMsg) + app.PushError(errMsg) + } + } + + go func() { + defer log.PanicHandler() + err := hooks.RunHook(&hooks.AercStartup{Version: Version}) + if err != nil { + msg := fmt.Sprintf("aerc-startup hook: %s", err) + app.PushError(msg) + } + }() + defer func(start time.Time) { + err := hooks.RunHook( + &hooks.AercShutdown{Lifetime: time.Since(start)}, + ) + if err != nil { + log.Errorf("aerc-shutdown hook: %s", err) + } + }(time.Now()) + var once sync.Once +loop: + for { + select { + case event := <-ui.Events: + ui.HandleEvent(event) + case msg := <-types.WorkerMessages: + app.HandleMessage(msg) + // XXX: The app may not be 100% ready at this point. + // The issue is that there is no real way to tell when + // it will be ready. And in some cases, it may never be. + // At least, we can be confident that accepting IPC + // commands will not crash the whole process. + once.Do(endStartup) + case callback := <-ui.Callbacks: + callback() + case <-ui.Redraw: + ui.Render() + case <-ui.SuspendQueue: + err = ui.Suspend() + if err != nil { + app.PushError(fmt.Sprintf("suspend: %s", err)) + } + case <-ui.Quit: + err = app.CloseBackends() + if err != nil { + log.Warnf("failed to close backends: %v", err) + } + break loop + } + } +} diff --git a/models/models.go b/models/models.go new file mode 100644 index 0000000..4803c9b --- /dev/null +++ b/models/models.go @@ -0,0 +1,313 @@ +package models + +import ( + "errors" + "fmt" + "io" + "strconv" + "strings" + "time" + + "git.sr.ht/~rjarry/aerc/lib/parse" + "github.com/emersion/go-message/mail" +) + +// Flags is an abstraction around the different flags which can be present in +// different email backends and represents a flag that we use in the UI. +type Flags uint32 + +const ( + // SeenFlag marks a message as having been seen previously + SeenFlag Flags = 1 << iota + + // RecentFlag marks a message as being recent + RecentFlag + + // AnsweredFlag marks a message as having been replied to + AnsweredFlag + + // ForwardedFlag marks a message as having been forwarded + ForwardedFlag + + // DeletedFlag marks a message as having been deleted + DeletedFlag + + // FlaggedFlag marks a message with a user flag + FlaggedFlag + + // DraftFlag marks a message as a draft + DraftFlag +) + +func (f Flags) Has(flags Flags) bool { + return f&flags == flags +} + +type Role string + +var Roles = map[string]Role{ + "all": AllRole, + "archive": ArchiveRole, + "drafts": DraftsRole, + "inbox": InboxRole, + "junk": JunkRole, + "sent": SentRole, + "trash": TrashRole, + "query": QueryRole, +} + +const ( + AllRole Role = "all" + ArchiveRole Role = "archive" + DraftsRole Role = "drafts" + InboxRole Role = "inbox" + JunkRole Role = "junk" + SentRole Role = "sent" + TrashRole Role = "trash" + // Custom aerc roles + QueryRole Role = "query" + // virtual node created by the directory tree + VirtualRole Role = "virtual" +) + +type Directory struct { + Name string + // Exists messages in the Directory + Exists int + // Recent messages in the Directory + Recent int + // Unseen messages in the Directory + Unseen int + // IANA role + Role Role +} + +type DirectoryInfo struct { + Name string + // The total number of messages in this mailbox. + Exists int + // The number of messages not seen since the last time the mailbox was opened. + Recent int + // The number of unread messages + Unseen int +} + +// Capabilities provides the backend capabilities +type Capabilities struct { + Sort bool + Thread bool + Extensions []string +} + +func (c *Capabilities) Has(s string) bool { + for _, ext := range c.Extensions { + if ext == s { + return true + } + } + return false +} + +type UID string + +func UidToUint32(uid UID) uint32 { + u, _ := strconv.ParseUint(string(uid), 10, 32) + return uint32(u) +} + +func Uint32ToUid(u uint32) UID { + return UID(fmt.Sprintf("%012d", u)) +} + +func UidToUint32List(uids []UID) []uint32 { + ulist := make([]uint32, 0, len(uids)) + for _, uid := range uids { + ulist = append(ulist, UidToUint32(uid)) + } + return ulist +} + +func Uint32ToUidList(ulist []uint32) []UID { + uids := make([]UID, 0, len(ulist)) + for _, u := range ulist { + uids = append(uids, Uint32ToUid(u)) + } + return uids +} + +// A MessageInfo holds information about the structure of a message +type MessageInfo struct { + BodyStructure *BodyStructure + Envelope *Envelope + Flags Flags + Labels []string + Filenames []string + InternalDate time.Time + RFC822Headers *mail.Header + Refs []string + Size uint32 + Uid UID + Error error +} + +func (mi *MessageInfo) MsgId() (msgid string, err error) { + if mi == nil { + return "", errors.New("msg is nil") + } + if mi.Envelope == nil { + return "", errors.New("envelope is nil") + } + return mi.Envelope.MessageId, nil +} + +func (mi *MessageInfo) InReplyTo() (msgid string, err error) { + if mi == nil { + return "", errors.New("msg is nil") + } + if mi.Envelope != nil && mi.Envelope.InReplyTo != "" { + return mi.Envelope.InReplyTo, nil + } + if mi.RFC822Headers == nil { + return "", errors.New("header is nil") + } + list := parse.MsgIDList(mi.RFC822Headers, "In-Reply-To") + if len(list) == 0 { + return "", errors.New("no results") + } + return list[0], err +} + +func (mi *MessageInfo) References() ([]string, error) { + if mi == nil { + return []string{}, errors.New("msg is nil") + } + if mi.Refs != nil { + return mi.Refs, nil + } + if mi.RFC822Headers == nil { + return []string{}, errors.New("header is nil") + } + list := parse.MsgIDList(mi.RFC822Headers, "References") + if len(list) == 0 { + return []string{}, errors.New("no results") + } + return list, nil +} + +// A MessageBodyPart can be displayed in the message viewer +type MessageBodyPart struct { + Reader io.Reader + Uid UID +} + +// A FullMessage is the entire message +type FullMessage struct { + Reader io.Reader + Uid UID +} + +type BodyStructure struct { + MIMEType string + MIMESubType string + Params map[string]string + Description string + Encoding string + Parts []*BodyStructure + Disposition string + DispositionParams map[string]string +} + +// PartAtIndex returns the BodyStructure at the requested index +func (bs *BodyStructure) PartAtIndex(index []int) (*BodyStructure, error) { + if len(index) == 0 { + return bs, nil + } + cur := index[0] + rest := index[1:] + // passed indexes are 1 based, we need to convert back to actual indexes + curidx := cur - 1 + if curidx < 0 { + return nil, fmt.Errorf("invalid index, expected 1 based input") + } + + // no children, base case + if len(bs.Parts) == 0 { + if len(rest) != 0 { + return nil, fmt.Errorf("more index levels given than available") + } + if cur == 1 { + return bs, nil + } else { + return nil, fmt.Errorf("invalid index %v for non multipart", cur) + } + } + + if cur > len(bs.Parts) { + return nil, fmt.Errorf("invalid index %v, only have %v children", + cur, len(bs.Parts)) + } + + return bs.Parts[curidx].PartAtIndex(rest) +} + +func (bs *BodyStructure) FullMIMEType() string { + mime := fmt.Sprintf("%s/%s", bs.MIMEType, bs.MIMESubType) + return strings.ToLower(mime) +} + +func (bs *BodyStructure) FileName() string { + if filename, ok := bs.DispositionParams["filename"]; ok { + return filename + } else if filename, ok := bs.Params["name"]; ok { + // workaround golang not supporting RFC2231 besides ASCII and UTF8 + return filename + } + return "" +} + +type Envelope struct { + Date time.Time + Subject string + From []*mail.Address + ReplyTo []*mail.Address + Sender []*mail.Address + To []*mail.Address + Cc []*mail.Address + Bcc []*mail.Address + MessageId string + InReplyTo string +} + +// OriginalMail is helper struct used for reply/forward +type OriginalMail struct { + Date time.Time + From string + Text string + MIMEType string + RFC822Headers *mail.Header + Folder string +} + +type SignatureValidity int32 + +const ( + UnknownValidity SignatureValidity = iota + Valid + InvalidSignature + UnknownEntity + UnsupportedMicalg + MicalgMismatch +) + +type MessageDetails struct { + IsEncrypted bool + IsSigned bool + SignedBy string // Primary identity of signing key + SignedByKeyId uint64 + SignatureValidity SignatureValidity + SignatureError string + DecryptedWith string // Primary Identity of decryption key + DecryptedWithKeyId uint64 // Public key id of decryption key + Body io.Reader + Micalg string +} diff --git a/models/templates.go b/models/templates.go new file mode 100644 index 0000000..566fdb1 --- /dev/null +++ b/models/templates.go @@ -0,0 +1,74 @@ +package models + +import ( + "time" + + "github.com/emersion/go-message/mail" +) + +// This interface needs to be implemented for compliance with aerc-templates(7) +type TemplateData interface { + Account() string + AccountBackend() string + AccountFrom() *mail.Address + Folder() string + To() []*mail.Address + Cc() []*mail.Address + Bcc() []*mail.Address + From() []*mail.Address + Peer() []*mail.Address + ReplyTo() []*mail.Address + Date() time.Time + DateAutoFormat(date time.Time) string + Header(name string) string + ThreadPrefix() string + ThreadCount() int + ThreadUnread() int + ThreadFolded() bool + ThreadContext() bool + ThreadOrphan() bool + Subject() string + SubjectBase() string + Number() int + Labels() []string + Filename() string + Filenames() []string + Flags() []string + IsReplied() bool + HasAttachment() bool + Attach(string) string + IsFlagged() bool + IsRecent() bool + IsUnread() bool + IsMarked() bool + IsDraft() bool + IsForwarded() bool + MessageId() string + Role() string + Size() int + OriginalText() string + OriginalDate() time.Time + OriginalFrom() []*mail.Address + OriginalMIMEType() string + OriginalHeader(name string) string + Recent(folders ...string) int + Unread(folders ...string) int + Exists(folders ...string) int + RUE(folders ...string) string + Connected() bool + ConnectionInfo() string + ContentInfo() string + StatusInfo() string + TrayInfo() string + PendingKeys() string + Style(string, string) string + StyleSwitch(string, ...Case) string + StyleMap([]string, ...Case) []string + Signature() string +} + +type Case interface { + Matches(string) bool + Value() string + Skip() bool +} diff --git a/stylesets/blue b/stylesets/blue new file mode 100644 index 0000000..7c1169f --- /dev/null +++ b/stylesets/blue @@ -0,0 +1,76 @@ +# vim: ft=dosini + +*.default=true +*.normal=true + +border.bg=#005f87 +title.bg=#005f87 + +title.fg=white +title.bold=true + +header.bold=true +header.fg=#005f87 + +tab.selected.fg=white +tab.selected.bg=#005f87 +tab.selected.bold=true +dirlist*.selected.bg=#005f87 +dirlist*.selected.fg=white +dirlist*.selected.bold=true + +*error.bold=true +*error.fg=red +*warning.fg=yellow +*success.fg=green + +statusline_default.bg=#303030 +statusline_error.fg=red + +msglist_unread.fg=#ffffff +msglist_unread.bold=true +msglist_deleted.fg=#666666 +msglist_*.selected.bg=#303030 +msglist_marked.fg=white +msglist_marked.selected.fg=white +msglist_marked.bg=#005f87 +msglist_marked.selected.bg=#005fff +msglist_pill.reverse=true + +part_*.fg=#ffffff +part_mimetype.fg=#005f87 +part_*.selected.fg=#ffffff +part_*.selected.bg=#005f87 +part_filename.selected.bold=true + +selector_focused.bold=true +selector_focused.bg=#005f87 +selector_focused.fg=white +selector_chooser.bold=true +selector_chooser.bg=#005f87 +selector_chooser.fg=white +default.selected.bold=true +default.selected.fg=white +default.selected.bg=#005f87 + +completion_pill.reverse=true +completion_default.selected.bg=#005f87 +completion_description.dim=true + +[viewer] +*.default=true +*.normal=true +url.fg=#ffffaf +url.underline=true +header.fg=#af87ff +signature.fg=#af87ff +diff_meta.fg=#ffffff +diff_meta.bold=true +diff_chunk.fg=#00cdcd +diff_add.fg=#00cd00 +diff_del.fg=#cd0000 +quote_1.fg=#5fafff +quote_2.fg=#ff8700 +quote_3.fg=#af87ff +quote_4.fg=#ff5fd7 +quote_x.fg=#808080 diff --git a/stylesets/default b/stylesets/default new file mode 100644 index 0000000..2d1f301 --- /dev/null +++ b/stylesets/default @@ -0,0 +1,83 @@ +# vim: ft=dosini +# +# aerc default styleset +# +# This styleset uses the terminal defaults as its base. +# More information on how to configure the styleset can be found in +# the aerc-stylesets(7) manpage. Please read the manual before +# modifying or creating a styleset. + +# Uncomment these two lines to reset all attributes (except in the [viewer] +# section) and start from scratch. +#*.default = true +#*.normal = true + +#*.selected.bg = 12 +#*.selected.fg = 15 +#*.selected.bold = true + +#statusline_*.dim = true + +#*warning.dim = false +#*warning.bold = true +#*warning.fg = 11 +#*success.dim = false +#*success.bold = true +#*success.fg = 10 +#*error.dim = false +#*error.bold = true +#*error.fg = 9 + +#border.fg = 12 +#border.bold = true +#title.bg = 12 +#title.fg = 15 +#title.bold = true + +#header.fg = 4 +#header.bold = true + +#msglist_unread.bold = true +#msglist_deleted.dim = true +#msglist_marked.bg = 6 +#msglist_marked.fg = 15 +#msglist_pill.bg = 12 +#msglist_pill.fg = 15 + +#part_mimetype.fg = 12 + +#selector_chooser.bold = true +#selector_focused.bold = true +#selector_focused.bg = 12 +#selector_focused.fg = 15 + +#completion_*.bg = 8 +#completion_pill.bg = 12 +#completion_default.fg = 15 +#completion_description.fg = 15 +#completion_description.dim = true + +#[viewer] +# Uncomment these two lines to reset all attributes in the [viewer] section. +#*.default = true +#*.normal = true +#url.underline = true +#url.fg = 3 +#header.bold = true +#header.fg = 4 +#signature.dim = true +#signature.fg = 4 +#diff_meta.bold = true +#diff_chunk.fg = 6 +#diff_chunk_func.fg = 6 +#diff_chunk_func.dim = true +#diff_add.fg = 2 +#diff_del.fg = 1 +#quote_1.fg = 6 +#quote_2.fg = 4 +#quote_3.fg = 6 +#quote_3.dim = true +#quote_4.fg = 4 +#quote_4.dim = true +#quote_x.fg = 5 +#quote_x.dim = true diff --git a/stylesets/dracula b/stylesets/dracula new file mode 100644 index 0000000..d22fb7e --- /dev/null +++ b/stylesets/dracula @@ -0,0 +1,77 @@ +# vim: ft=dosini +# +# aerc dracula styleset +# + +*.default=true +*.normal=true + +#border.bg=#BD93F9 +title.bg=#BD93F9 + +title.fg=black +title.bold=true + +header.bold=true +header.fg=#BD93F9 + +tab.selected.fg=black +tab.selected.bg=#BD93F9 +tab.selected.bold=false +dirlist*.selected.bg=#44475A +dirlist*.selected.fg=white +dirlist*.selected.bold=false +dirlist*.selected.italic=false + +*error.bold=true +*error.fg=red +*warning.fg=yellow +*success.fg=green + +statusline_default.bg=#303030 +statusline_error.fg=red + +msglist_unread.fg=#ffffff +msglist_unread.bold=true +msglist_deleted.fg=#666666 +msglist_*.selected.bg=#44475A +msglist_result.bg=#6272A4 +msglist_marked.fg=black +msglist_marked.selected.fg=black +msglist_marked.bg=#BD93F9 +msglist_marked.selected.bg=#9956f5 +msglist_pill.reverse=true + +part_*.fg=#ffffff +part_mimetype.fg=#44475A +part_*.selected.fg=#ffffff +part_*.selected.bg=#44475A +part_filename.selected.bold=true + +completion_pill.reverse=false +selector_focused.bold=false +selector_focused.bg=#44475A +selector_focused.fg=white +selector_chooser.bold=false +selector_chooser.bg=#44475A +selector_chooser.fg=white +default.selected.bold=false +default.selected.fg=white +default.selected.bg=#44475A + +completion_default.selected.bg=#44475A +completion_default.selected.fg=white + +[viewer] +*.default=true +*.normal=true +url.underline=true +header.bold=true +signature.dim=true +diff_meta.bold=true +diff_chunk.dim=true +diff_add.fg=2 +diff_del.fg=1 +quote_*.fg=6 +quote_*.dim=true +quote_1.dim=false diff --git a/stylesets/monochrome b/stylesets/monochrome new file mode 100644 index 0000000..00b7f51 --- /dev/null +++ b/stylesets/monochrome @@ -0,0 +1,47 @@ +# vim: ft=dosini +# +# aerc monochrome styleset +# + +*.default = true +*.normal = true + +*.selected.reverse = toggle +*_pill.reverse = true + +title.reverse = true +title.bold = true +header.bold = true +tab.selected.bold = true +dirlist*.selected.bold = true + +statusline_default.reverse = true +*error.bold = true +*error.fg = 1 +*warning.fg = 3 + +msglist_unread.bold = true +msglist_deleted.dim = true +msglist_result.underline = true + +part_mimetype.dim = true +part_mimetype.selected.dim = false +part_filename.selected.bold = true + +selector_focused.reverse = true +selector_chooser.bold = true +completion_description.dim = true + +[viewer] +*.default = true +*.normal = true +url.underline = true +header.bold = true +signature.dim = true +diff_meta.bold = true +diff_chunk*.dim = true +diff_add.fg = 2 +diff_del.fg = 1 +quote_*.fg = 6 +quote_*.dim = true +quote_1.dim = false diff --git a/stylesets/nord b/stylesets/nord new file mode 100644 index 0000000..d8388b9 --- /dev/null +++ b/stylesets/nord @@ -0,0 +1,68 @@ +# +# aerc nord styleset +# + +*.default=true +*.normal=true + +title.reverse=true +header.bold=true + +*error.bold=true +error.fg=red +warning.fg=yellow +success.fg=green + +statusline*.default=true +statusline_default.reverse=true +statusline_error.reverse=true + +completion_pill.reverse=true +completion_description.dim=true + +border.fg = #49576b + +selector_focused.reverse=true +selector_chooser.bold=true + +# Colors: Nord + +*.selected.bg=#394353 + +msglist_marked.bg=#81a1c1 +msglist_flagged.fg=#a3be8c +msglist_flagged.bold=true + +msglist_unread.fg=#8fbcbb + +msglist_pill.reverse=true + +statusline_default.fg=#49576b +statusline_error.fg=#94545d + +tab.fg=#e4e9f0 +tab.bg=#49576b +tab.selected.bg=#64A6B3 +tab.selected.fg=#2c3441 + +dirlist_unread.fg=#64A6B3 +dirlist_recent.fg=#64A6B3 + +part_*.fg=#ffffff +part_mimetype.fg=#394353 +part_*.selected.fg=#ffffff +part_filename.selected.bold=true + +[viewer] +*.default=true +*.normal=true +url.underline=true +header.bold=true +signature.dim=true +diff_meta.bold=true +diff_chunk.dim=true +diff_add.fg=2 +diff_del.fg=1 +quote_*.fg=6 +quote_*.dim=true +quote_1.dim=false diff --git a/stylesets/pink b/stylesets/pink new file mode 100644 index 0000000..65ef2fe --- /dev/null +++ b/stylesets/pink @@ -0,0 +1,76 @@ +# vim: ft=dosini + +*.default=true +*.normal=true + +border.bg=#de4e85 +title.bg=#de4e85 + +title.fg=white +title.bold=true + +header.bold=true +header.fg=#de4e85 + +tab.selected.fg=white +tab.selected.bg=#de4e85 +tab.selected.bold=true +dirlist*.selected.bg=#de4e85 +dirlist*.selected.fg=white +dirlist*.selected.bold=true + +*error.bold=true +*error.fg=red +*warning.fg=yellow +*success.fg=green + +statusline_default.bg=#303030 +statusline_error.fg=red + +msglist_unread.fg=#ffffff +msglist_unread.bold=true +msglist_deleted.fg=#666666 +msglist_*.selected.bg=#303030 +msglist_marked.fg=white +msglist_marked.selected.fg=white +msglist_marked.bg=#de4e85 +msglist_marked.selected.bg=#c93687 +msglist_pill.reverse=true + +part_*.fg=#ffffff +part_mimetype.fg=#de4e85 +part_*.selected.fg=#ffffff +part_*.selected.bg=#de4e85 +part_filename.selected.bold=true + +selector_focused.bold=true +selector_focused.bg=#de4e85 +selector_focused.fg=white +selector_chooser.bold=true +selector_chooser.bg=#de4e85 +selector_chooser.fg=white +default.selected.bold=true +default.selected.fg=white +default.selected.bg=#de4e85 + +completion_pill.reverse=true +completion_default.selected.bg=#de4e85 +completion_description.dim=true + +[viewer] +*.default=true +*.normal=true +url.fg=#ffffaf +url.underline=true +header.fg=#af87ff +signature.fg=#af87ff +diff_meta.fg=#ffffff +diff_meta.bold=true +diff_chunk.fg=#00cdcd +diff_add.fg=#00cd00 +diff_del.fg=#cd0000 +quote_1.fg=#5fafff +quote_2.fg=#ff8700 +quote_3.fg=#af87ff +quote_4.fg=#ff5fd7 +quote_x.fg=#808080 diff --git a/stylesets/solarized b/stylesets/solarized new file mode 100644 index 0000000..9308d78 --- /dev/null +++ b/stylesets/solarized @@ -0,0 +1,62 @@ +# vim: ft=dosini +# +# aerc solarized styleset +# + +*.default=true +*.normal=true +*error.bold=true +border.reverse=true +completion_pill.reverse=true +completion_description.dim=true +error.fg=#dc322f # red +header.bold=true +selector_chooser.bold=true +statusline*.default=true +success.fg=#859900 # green +warning.fg=#b58900 # yellow + +*.selected.bg=#b58900 +*.selected.fg=#3b4252 +dirlist_recent.fg=#b58900 +dirlist_unread.fg=#b58900 +msglist_flagged.bold=true +msglist_flagged.fg=#a3be8c +msglist_marked.bg=#81a1c1 +msglist_unread.fg=#b58900 +msglist_unread.selected.bg=#b58900 +msglist_unread.selected.fg=#002b36 +msglist_pill.reverse=true +statusline_*.bg=#859900 +statusline_*.fg=#002b36 +statusline_error.bg=#d33682 +statusline_error.fg=#002b36 +tab.bg=#eee8d5 +tab.fg=#586e75 +tab.selected.bg=#b58900 +tab.selected.fg=#002b36 +part_mimetype.fg=#b58900 +part_filename.selected.bold=true + +[viewer] +*.default=true +*.normal=true +diff_add.fg=#859900 # green +diff_chunk.dim=true +diff_chunk.fg=#2aa198 # cyan +diff_del.fg=#dc322f # red +diff_meta.bold=true +diff_meta.fg=#839496 # bright blue +header.bold=true +header.fg=#d33682 # magenta +quote_*.dim=true +quote_*.fg=#93a1a1 # bright cyan +quote_1.dim=false +quote_1.fg=#268bd2 # blue +quote_2.fg=#cb4b16 # bright red +quote_3.fg=#d33682 # magenta +quote_4.fg=#6c71c4 # bright magenta +signature.dim=true +signature.fg=#d33682 # magenta +url.fg=#b58900 # yellow +url.underline=true diff --git a/templates/forward_as_body b/templates/forward_as_body new file mode 100644 index 0000000..e8d15dc --- /dev/null +++ b/templates/forward_as_body @@ -0,0 +1,8 @@ +X-Mailer: aerc {{version}} + +Forwarded message from {{.OriginalFrom | names | join ", "}} on {{dateFormat .OriginalDate "Mon Jan 2, 2006 at 3:04 PM"}}: +{{.OriginalText}} +{{- with .Signature }} + +{{.}} +{{- end }} diff --git a/templates/new_message b/templates/new_message new file mode 100644 index 0000000..8ff5f46 --- /dev/null +++ b/templates/new_message @@ -0,0 +1,5 @@ +X-Mailer: aerc {{version}} +{{- with .Signature }} + +{{.}} +{{- end -}} diff --git a/templates/quoted_reply b/templates/quoted_reply new file mode 100644 index 0000000..5ca7eb0 --- /dev/null +++ b/templates/quoted_reply @@ -0,0 +1,12 @@ +X-Mailer: aerc {{version}} + +On {{dateFormat (.OriginalDate | toLocal) "Mon Jan 2, 2006 at 3:04 PM MST"}}, {{.OriginalFrom | names | join ", "}} wrote: +{{ if eq .OriginalMIMEType "text/html" -}} +{{- exec `html` .OriginalText | trimSignature | quote -}} +{{- else -}} +{{- trimSignature .OriginalText | quote -}} +{{- end}} +{{- with .Signature }} + +{{.}} +{{- end }} diff --git a/worker/handler_notmuch.go b/worker/handler_notmuch.go new file mode 100644 index 0000000..8944d28 --- /dev/null +++ b/worker/handler_notmuch.go @@ -0,0 +1,6 @@ +//go:build notmuch +// +build notmuch + +package worker + +import _ "git.sr.ht/~rjarry/aerc/worker/notmuch" diff --git a/worker/handlers/register.go b/worker/handlers/register.go new file mode 100644 index 0000000..c871f07 --- /dev/null +++ b/worker/handlers/register.go @@ -0,0 +1,27 @@ +package handlers + +import ( + "fmt" + + "git.sr.ht/~rjarry/aerc/worker/types" +) + +type FactoryFunc func(*types.Worker) (types.Backend, error) + +var workerFactories map[string]FactoryFunc = make(map[string]FactoryFunc) + +func RegisterWorkerFactory(scheme string, factory FactoryFunc) { + workerFactories[scheme] = factory +} + +func GetHandlerForScheme(scheme string, worker *types.Worker) (types.Backend, error) { + factory, ok := workerFactories[scheme] + if !ok { + return nil, fmt.Errorf("Unknown backend %s", scheme) + } + backend, err := factory(worker) + if err != nil { + return nil, err + } + return backend, nil +} diff --git a/worker/imap/cache.go b/worker/imap/cache.go new file mode 100644 index 0000000..0eba7b9 --- /dev/null +++ b/worker/imap/cache.go @@ -0,0 +1,212 @@ +package imap + +import ( + "bufio" + "bytes" + "encoding/gob" + "errors" + "fmt" + "strings" + "time" + + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/parse" + "git.sr.ht/~rjarry/aerc/lib/xdg" + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/types" + "github.com/emersion/go-message" + "github.com/emersion/go-message/mail" + "github.com/emersion/go-message/textproto" + "github.com/syndtr/goleveldb/leveldb" +) + +type CachedHeader struct { + BodyStructure models.BodyStructure + Envelope models.Envelope + InternalDate time.Time + Uid models.UID + Size uint32 + Header []byte + Created time.Time +} + +var ( + // cacheTag should be updated when changing the cache + // structure; this will ensure that the user's cache is cleared and + // reloaded when the underlying cache structure changes + cacheTag = []byte("0003") + cacheTagKey = []byte("cache.tag") +) + +// initCacheDb opens (or creates) the database for the cache. One database is +// created per account +func (w *IMAPWorker) initCacheDb(acct string) { + switch { + case len(w.config.headersExclude) > 0: + headerTag := strings.Join(w.config.headersExclude, "") + cacheTag = append(cacheTag, headerTag...) + case len(w.config.headers) > 0: + headerTag := strings.Join(w.config.headers, "") + cacheTag = append(cacheTag, headerTag...) + } + p := xdg.CachePath("aerc", acct) + db, err := leveldb.OpenFile(p, nil) + if err != nil { + w.cache = nil + w.worker.Errorf("failed opening cache db: %v", err) + return + } + w.cache = db + w.worker.Debugf("cache db opened: %s", p) + + tag, err := w.cache.Get(cacheTagKey, nil) + clearCache := errors.Is(err, leveldb.ErrNotFound) || + !bytes.Equal(tag, cacheTag) + switch { + case clearCache: + w.worker.Infof("current cache tag is '%s' but found '%s'", + cacheTag, tag) + w.worker.Warnf("tag mismatch: clear cache") + w.clearCache() + if err = w.cache.Put(cacheTagKey, cacheTag, nil); err != nil { + w.worker.Errorf("could not set the current cache tag") + } + case err != nil: + w.worker.Errorf("could not get the cache tag from db") + default: + if w.config.cacheMaxAge.Hours() > 0 { + go w.cleanCache(p) + } + } +} + +func (w *IMAPWorker) cacheHeader(mi *models.MessageInfo) { + key := w.headerKey(mi.Uid) + w.worker.Debugf("caching header for message %s", key) + hdr := bytes.NewBuffer(nil) + err := textproto.WriteHeader(hdr, mi.RFC822Headers.Header.Header) + if err != nil { + w.worker.Errorf("cannot write header %s: %v", key, err) + return + } + h := &CachedHeader{ + BodyStructure: *mi.BodyStructure, + Envelope: *mi.Envelope, + InternalDate: mi.InternalDate, + Uid: mi.Uid, + Size: mi.Size, + Header: hdr.Bytes(), + Created: time.Now(), + } + data := bytes.NewBuffer(nil) + enc := gob.NewEncoder(data) + err = enc.Encode(h) + if err != nil { + w.worker.Errorf("cannot encode message %s: %v", key, err) + return + } + err = w.cache.Put(key, data.Bytes(), nil) + if err != nil { + w.worker.Errorf("cannot write header for message %s: %v", key, err) + return + } +} + +func (w *IMAPWorker) getCachedHeaders(msg *types.FetchMessageHeaders) []models.UID { + w.worker.Tracef("Retrieving headers from cache: %v", msg.Uids) + var need []models.UID + for _, uid := range msg.Uids { + key := w.headerKey(uid) + data, err := w.cache.Get(key, nil) + if err != nil { + need = append(need, uid) + continue + } + ch := &CachedHeader{} + dec := gob.NewDecoder(bytes.NewReader(data)) + err = dec.Decode(ch) + if err != nil { + w.worker.Errorf("cannot decode cached header %s: %v", key, err) + need = append(need, uid) + continue + } + hr := bytes.NewReader(ch.Header) + textprotoHeader, err := textproto.ReadHeader(bufio.NewReader(hr)) + if err != nil { + w.worker.Errorf("cannot read cached header %s: %v", key, err) + need = append(need, uid) + continue + } + + hdr := &mail.Header{Header: message.Header{Header: textprotoHeader}} + mi := &models.MessageInfo{ + BodyStructure: &ch.BodyStructure, + Envelope: &ch.Envelope, + Flags: models.SeenFlag, // Always return a SEEN flag + Uid: ch.Uid, + RFC822Headers: hdr, + Refs: parse.MsgIDList(hdr, "references"), + Size: ch.Size, + } + w.worker.PostMessage(&types.MessageInfo{ + Message: types.RespondTo(msg), + Info: mi, + NeedsFlags: true, + }, nil) + } + return need +} + +func (w *IMAPWorker) headerKey(uid models.UID) []byte { + key := fmt.Sprintf("header.%s.%d.%s", + w.selected.Name, w.selected.UidValidity, uid) + return []byte(key) +} + +// cleanCache removes stale entries from the selected mailbox cachedb +func (w *IMAPWorker) cleanCache(path string) { + defer log.PanicHandler() + start := time.Now() + var scanned, removed int + iter := w.cache.NewIterator(nil, nil) + for iter.Next() { + if bytes.Equal(iter.Key(), cacheTagKey) { + continue + } + data := iter.Value() + ch := &CachedHeader{} + dec := gob.NewDecoder(bytes.NewReader(data)) + err := dec.Decode(ch) + if err != nil { + w.worker.Errorf("cannot clean database %d: %v", + w.selected.UidValidity, err) + continue + } + exp := ch.Created.Add(w.config.cacheMaxAge) + if exp.Before(time.Now()) { + err = w.cache.Delete(iter.Key(), nil) + if err != nil { + w.worker.Errorf("cannot clean database %d: %v", + w.selected.UidValidity, err) + continue + } + removed++ + } + scanned++ + } + iter.Release() + elapsed := time.Since(start) + w.worker.Debugf("%s: removed %d/%d expired entries in %s", + path, removed, scanned, elapsed) +} + +// clearCache clears the entire cache +func (w *IMAPWorker) clearCache() { + iter := w.cache.NewIterator(nil, nil) + for iter.Next() { + if err := w.cache.Delete(iter.Key(), nil); err != nil { + w.worker.Errorf("error clearing cache: %v", err) + } + } + iter.Release() +} diff --git a/worker/imap/checkmail.go b/worker/imap/checkmail.go new file mode 100644 index 0000000..ae20a5b --- /dev/null +++ b/worker/imap/checkmail.go @@ -0,0 +1,76 @@ +package imap + +import ( + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/types" + "github.com/emersion/go-imap" +) + +func (w *IMAPWorker) handleCheckMailMessage(msg *types.CheckMail) { + items := []imap.StatusItem{ + imap.StatusMessages, + imap.StatusRecent, + imap.StatusUnseen, + imap.StatusUidNext, + } + var ( + statuses []*imap.MailboxStatus + err error + remaining []string + ) + switch { + case w.liststatus: + w.worker.Tracef("Checking mail with LIST-STATUS") + statuses, err = w.client.liststatus.ListStatus("", "*", items, nil) + if err != nil { + w.worker.PostMessage(&types.Error{ + Message: types.RespondTo(msg), + Error: err, + }, nil) + return + } + default: + for _, dir := range msg.Directories { + if len(w.worker.Actions()) > 0 { + remaining = append(remaining, dir) + continue + } + w.worker.Tracef("Getting status of directory %s", dir) + status, err := w.client.Status(dir, items) + if err != nil { + w.worker.PostMessage(&types.Error{ + Message: types.RespondTo(msg), + Error: err, + }, nil) + continue + } + statuses = append(statuses, status) + } + } + for _, status := range statuses { + refetch := false + if status.Name == w.selected.Name { + if status.UidNext != w.selected.UidNext { + refetch = true + } + w.selected = status + } + w.worker.PostMessage(&types.DirectoryInfo{ + Info: &models.DirectoryInfo{ + Name: status.Name, + Exists: int(status.Messages), + Recent: int(status.Recent), + Unseen: int(status.Unseen), + }, + Refetch: refetch, + }, nil) + } + if len(remaining) > 0 { + w.worker.PostMessage(&types.CheckMailDirectories{ + Message: types.RespondTo(msg), + Directories: remaining, + }, nil) + return + } + w.worker.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil) +} diff --git a/worker/imap/configure.go b/worker/imap/configure.go new file mode 100644 index 0000000..9d4a02d --- /dev/null +++ b/worker/imap/configure.go @@ -0,0 +1,187 @@ +package imap + +import ( + "bufio" + "fmt" + "net/url" + "os" + "strconv" + "strings" + "time" + + "git.sr.ht/~rjarry/aerc/lib/xdg" + "git.sr.ht/~rjarry/aerc/worker/lib" + "git.sr.ht/~rjarry/aerc/worker/middleware" + "git.sr.ht/~rjarry/aerc/worker/types" + "golang.org/x/oauth2" +) + +func (w *IMAPWorker) handleConfigure(msg *types.Configure) error { + w.config.name = msg.Config.Name + u, err := url.Parse(msg.Config.Source) + if err != nil { + return err + } + + w.config.scheme = u.Scheme + if strings.HasSuffix(w.config.scheme, "+insecure") { + w.config.scheme = strings.TrimSuffix(w.config.scheme, "+insecure") + w.config.insecure = true + } + + if strings.HasSuffix(w.config.scheme, "+oauthbearer") { + w.config.scheme = strings.TrimSuffix(w.config.scheme, "+oauthbearer") + w.config.oauthBearer.Enabled = true + q := u.Query() + + oauth2 := &oauth2.Config{} + if q.Get("token_endpoint") != "" { + oauth2.ClientID = q.Get("client_id") + oauth2.ClientSecret = q.Get("client_secret") + oauth2.Scopes = []string{q.Get("scope")} + oauth2.Endpoint.TokenURL = q.Get("token_endpoint") + } + w.config.oauthBearer.OAuth2 = oauth2 + } + + if strings.HasSuffix(w.config.scheme, "+xoauth2") { + w.config.scheme = strings.TrimSuffix(w.config.scheme, "+xoauth2") + w.config.xoauth2.Enabled = true + q := u.Query() + + oauth2 := &oauth2.Config{} + if q.Get("token_endpoint") != "" { + oauth2.ClientID = q.Get("client_id") + oauth2.ClientSecret = q.Get("client_secret") + oauth2.Scopes = []string{q.Get("scope")} + oauth2.Endpoint.TokenURL = q.Get("token_endpoint") + } + w.config.xoauth2.OAuth2 = oauth2 + } + + w.config.addr = u.Host + if !strings.ContainsRune(w.config.addr, ':') { + w.config.addr += ":" + w.config.scheme + } + + w.config.user = u.User + w.config.folders = msg.Config.Folders + w.config.headers = msg.Config.Headers + w.config.headersExclude = msg.Config.HeadersExclude + + w.config.idle_timeout = 10 * time.Second + w.config.idle_debounce = 10 * time.Millisecond + + w.config.connection_timeout = 30 * time.Second + w.config.keepalive_period = 0 * time.Second + w.config.keepalive_probes = 3 + w.config.keepalive_interval = 3 + + w.config.reconnect_maxwait = 30 * time.Second + + w.config.cacheEnabled = false + w.config.cacheMaxAge = 30 * 24 * time.Hour // 30 days + + for key, value := range msg.Config.Params { + switch key { + case "idle-timeout": + val, err := time.ParseDuration(value) + if err != nil || val < 0 { + return fmt.Errorf( + "invalid idle-timeout value %v: %w", + value, err) + } + w.config.idle_timeout = val + case "idle-debounce": + val, err := time.ParseDuration(value) + if err != nil || val < 0 { + return fmt.Errorf( + "invalid idle-debounce value %v: %w", + value, err) + } + w.config.idle_debounce = val + case "reconnect-maxwait": + val, err := time.ParseDuration(value) + if err != nil || val < 0 { + return fmt.Errorf( + "invalid reconnect-maxwait value %v: %w", + value, err) + } + w.config.reconnect_maxwait = val + case "connection-timeout": + val, err := time.ParseDuration(value) + if err != nil || val < 0 { + return fmt.Errorf( + "invalid connection-timeout value %v: %w", + value, err) + } + w.config.connection_timeout = val + case "keepalive-period": + val, err := time.ParseDuration(value) + if err != nil || val < 0 { + return fmt.Errorf( + "invalid keepalive-period value %v: %w", + value, err) + } + w.config.keepalive_period = val + case "keepalive-probes": + val, err := strconv.Atoi(value) + if err != nil || val < 0 { + return fmt.Errorf( + "invalid keepalive-probes value %v: %w", + value, err) + } + w.config.keepalive_probes = val + case "keepalive-interval": + val, err := time.ParseDuration(value) + if err != nil || val < 0 { + return fmt.Errorf( + "invalid keepalive-interval value %v: %w", + value, err) + } + w.config.keepalive_interval = int(val.Seconds()) + case "cache-headers": + cache, err := strconv.ParseBool(value) + if err != nil { + // Return an error here because the user tried to set header + // caching, and we want them to know they didn't set it right - + // one way or the other + return fmt.Errorf("invalid cache-headers value %v: %w", value, err) + } + w.config.cacheEnabled = cache + case "cache-max-age": + val, err := time.ParseDuration(value) + if err != nil || val < 0 { + return fmt.Errorf("invalid cache-max-age value %v: %w", value, err) + } + w.config.cacheMaxAge = val + case "use-gmail-ext": + val, err := strconv.ParseBool(value) + if err != nil { + return fmt.Errorf("invalid use-gmail-ext value %v: %w", value, err) + } + w.config.useXGMEXT = val + } + } + if w.config.cacheEnabled { + w.initCacheDb(msg.Config.Name) + } + w.idler = newIdler(w.config, w.worker, w.executeIdle) + w.observer = newObserver(w.config, w.worker) + + if name, ok := msg.Config.Params["folder-map"]; ok { + file := xdg.ExpandHome(name) + f, err := os.Open(file) + if err != nil { + return err + } + defer f.Close() + fmap, order, err := lib.ParseFolderMap(bufio.NewReader(f)) + if err != nil { + return err + } + w.worker = middleware.NewFolderMapper(w.worker, fmap, order) + } + + return nil +} diff --git a/worker/imap/connect.go b/worker/imap/connect.go new file mode 100644 index 0000000..6e785a7 --- /dev/null +++ b/worker/imap/connect.go @@ -0,0 +1,196 @@ +package imap + +import ( + "crypto/tls" + "fmt" + "net" + "time" + + "git.sr.ht/~rjarry/aerc/lib" + "git.sr.ht/~rjarry/aerc/lib/log" + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/client" + "github.com/emersion/go-sasl" +) + +// connect establishes a new tcp connection to the imap server, logs in and +// selects the default inbox. If no error is returned, the imap client will be +// in the imap.SelectedState. +func (w *IMAPWorker) connect() (*client.Client, error) { + var ( + conn *net.TCPConn + err error + c *client.Client + ) + + conn, err = newTCPConn(w.config.addr, w.config.connection_timeout) + if conn == nil || err != nil { + return nil, err + } + + if w.config.connection_timeout > 0 { + end := time.Now().Add(w.config.connection_timeout) + err = conn.SetDeadline(end) + if err != nil { + return nil, err + } + } + + if w.config.keepalive_period > 0 { + err = w.setKeepaliveParameters(conn) + if err != nil { + return nil, err + } + } + + serverName, _, _ := net.SplitHostPort(w.config.addr) + tlsConfig := &tls.Config{ServerName: serverName} + + switch w.config.scheme { + case "imap": + c, err = client.New(conn) + if err != nil { + return nil, err + } + if !w.config.insecure { + if err = c.StartTLS(tlsConfig); err != nil { + return nil, err + } + } + case "imaps": + if w.config.insecure { + tlsConfig.InsecureSkipVerify = true + } + tlsConn := tls.Client(conn, tlsConfig) + c, err = client.New(tlsConn) + if err != nil { + return nil, err + } + default: + return nil, fmt.Errorf("Unknown IMAP scheme %s", w.config.scheme) + } + + c.ErrorLog = log.ErrorLogger() + + if w.config.user != nil { + username := w.config.user.Username() + + // TODO: 2nd parameter false if no password is set. ask for it + // if unset. + password, _ := w.config.user.Password() + + if w.config.oauthBearer.Enabled { + if err := w.config.oauthBearer.Authenticate( + username, password, c); err != nil { + return nil, err + } + } else if w.config.xoauth2.Enabled { + if err := w.config.xoauth2.Authenticate( + username, password, w.config.name, c); err != nil { + return nil, err + } + } else if plain, err := c.SupportAuth("PLAIN"); err != nil { + return nil, err + } else if plain { + auth := sasl.NewPlainClient("", username, password) + if err := c.Authenticate(auth); err != nil { + return nil, err + } + } else if err := c.Login(username, password); err != nil { + return nil, err + } + } + + if _, err := c.Select(imap.InboxName, false); err != nil { + return nil, err + } + + info := make(chan *imap.MailboxInfo, 1) + if err := c.List("", "", info); err != nil { + return nil, fmt.Errorf("failed to retrieve delimiter: %w", err) + } + if mailboxinfo := <-info; mailboxinfo != nil { + w.delimiter = mailboxinfo.Delimiter + } + if w.delimiter == "" { + // just in case some implementation does not follow standards + w.delimiter = "/" + } + + return c, nil +} + +// newTCPConn establishes a new tcp connection. Timeout will ensure that the +// function does not hang when there is no connection. If there is a timeout, +// but a valid connection is eventually returned, ensure that it is properly +// closed. +func newTCPConn(addr string, timeout time.Duration) (*net.TCPConn, error) { + errTCPTimeout := fmt.Errorf("tcp connection timeout") + + type tcpConn struct { + conn *net.TCPConn + err error + } + + done := make(chan tcpConn) + go func() { + defer log.PanicHandler() + + newConn, err := net.Dial("tcp", addr) + if err != nil { + done <- tcpConn{nil, err} + return + } + done <- tcpConn{newConn.(*net.TCPConn), nil} + }() + + select { + case <-time.After(timeout): + go func() { + defer log.PanicHandler() + if tcpResult := <-done; tcpResult.conn != nil { + tcpResult.conn.Close() + } + }() + return nil, errTCPTimeout + case tcpResult := <-done: + if tcpResult.conn == nil || tcpResult.err != nil { + return nil, tcpResult.err + } + return tcpResult.conn, nil + } +} + +// Set additional keepalive parameters. +// Uses new interfaces introduced in Go1.11, which let us get connection's file +// descriptor, without blocking, and therefore without uncontrolled spawning of +// threads (not goroutines, actual threads). +func (w *IMAPWorker) setKeepaliveParameters(conn *net.TCPConn) error { + err := conn.SetKeepAlive(true) + if err != nil { + return err + } + // Idle time before sending a keepalive probe + err = conn.SetKeepAlivePeriod(w.config.keepalive_period) + if err != nil { + return err + } + rawConn, e := conn.SyscallConn() + if e != nil { + return e + } + err = rawConn.Control(func(fdPtr uintptr) { + fd := int(fdPtr) + // Max number of probes before failure + err := lib.SetTcpKeepaliveProbes(fd, w.config.keepalive_probes) + if err != nil { + w.worker.Errorf("cannot set tcp keepalive probes: %v", err) + } + // Wait time after an unsuccessful probe + err = lib.SetTcpKeepaliveInterval(fd, w.config.keepalive_interval) + if err != nil { + w.worker.Errorf("cannot set tcp keepalive interval: %v", err) + } + }) + return err +} diff --git a/worker/imap/create.go b/worker/imap/create.go new file mode 100644 index 0000000..5bac001 --- /dev/null +++ b/worker/imap/create.go @@ -0,0 +1,19 @@ +package imap + +import ( + "git.sr.ht/~rjarry/aerc/worker/types" +) + +func (imapw *IMAPWorker) handleCreateDirectory(msg *types.CreateDirectory) { + if err := imapw.client.Create(msg.Directory); err != nil { + if msg.Quiet { + return + } + imapw.worker.PostMessage(&types.Error{ + Message: types.RespondTo(msg), + Error: err, + }, nil) + } else { + imapw.worker.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil) + } +} diff --git a/worker/imap/extensions/liststatus.go b/worker/imap/extensions/liststatus.go new file mode 100644 index 0000000..44b93a7 --- /dev/null +++ b/worker/imap/extensions/liststatus.go @@ -0,0 +1,149 @@ +package extensions + +import ( + "fmt" + "strings" + + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/client" + "github.com/emersion/go-imap/responses" + "github.com/emersion/go-imap/utf7" +) + +// A LIST-STATUS client +type ListStatusClient struct { + c *client.Client +} + +func NewListStatusClient(c *client.Client) *ListStatusClient { + return &ListStatusClient{c} +} + +// SupportListStatus checks if the server supports the LIST-STATUS extension. +func (c *ListStatusClient) SupportListStatus() (bool, error) { + return c.c.Support("LIST-STATUS") +} + +// ListStatus performs a LIST-STATUS command, listing mailboxes and also +// retrieving the requested status items. A nil channel can be passed in order +// to only retrieve the STATUS responses +func (c *ListStatusClient) ListStatus( + ref string, + name string, + items []imap.StatusItem, + ch chan *imap.MailboxInfo, +) ([]*imap.MailboxStatus, error) { + if ch != nil { + defer close(ch) + } + + if c.c.State() != imap.AuthenticatedState && c.c.State() != imap.SelectedState { + return nil, client.ErrNotLoggedIn + } + + cmd := &ListStatusCommand{ + Reference: ref, + Mailbox: name, + Items: items, + } + res := &ListStatusResponse{Mailboxes: ch} + + status, err := c.c.Execute(cmd, res) + if err != nil { + return nil, err + } + return res.Statuses, status.Err() +} + +// ListStatusCommand is a LIST command, as defined in RFC 3501 section 6.3.8. If +// Subscribed is set to true, LSUB will be used instead. Mailbox statuses will +// be returned if Items is not nil +type ListStatusCommand struct { + Reference string + Mailbox string + + Subscribed bool + Items []imap.StatusItem +} + +func (cmd *ListStatusCommand) Command() *imap.Command { + name := "LIST" + if cmd.Subscribed { + name = "LSUB" + } + + enc := utf7.Encoding.NewEncoder() + ref, _ := enc.String(cmd.Reference) + mailbox, _ := enc.String(cmd.Mailbox) + + items := make([]string, len(cmd.Items)) + if cmd.Items != nil { + for i, item := range cmd.Items { + items[i] = string(item) + } + } + + args := fmt.Sprintf("RETURN (STATUS (%s))", strings.Join(items, " ")) + return &imap.Command{ + Name: name, + Arguments: []interface{}{ref, mailbox, imap.RawString(args)}, + } +} + +// A LIST-STATUS response +type ListStatusResponse struct { + Mailboxes chan *imap.MailboxInfo + Subscribed bool + Statuses []*imap.MailboxStatus +} + +func (r *ListStatusResponse) Name() string { + if r.Subscribed { + return "LSUB" + } else { + return "LIST" + } +} + +func (r *ListStatusResponse) Handle(resp imap.Resp) error { + name, _, ok := imap.ParseNamedResp(resp) + if !ok { + return responses.ErrUnhandled + } + switch name { + case "LIST": + if r.Mailboxes == nil { + return nil + } + res := responses.List{Mailboxes: r.Mailboxes} + return res.Handle(resp) + case "STATUS": + res := responses.Status{ + Mailbox: new(imap.MailboxStatus), + } + err := res.Handle(resp) + if err != nil { + return err + } + r.Statuses = append(r.Statuses, res.Mailbox) + default: + return responses.ErrUnhandled + } + + return nil +} + +func (r *ListStatusResponse) WriteTo(w *imap.Writer) error { + respName := r.Name() + + for mbox := range r.Mailboxes { + fields := []interface{}{imap.RawString(respName)} + fields = append(fields, mbox.Format()...) + + resp := imap.NewUntaggedResp(fields) + if err := resp.WriteTo(w); err != nil { + return err + } + } + return nil +} diff --git a/worker/imap/extensions/xgmext/client.go b/worker/imap/extensions/xgmext/client.go new file mode 100644 index 0000000..2d2573e --- /dev/null +++ b/worker/imap/extensions/xgmext/client.go @@ -0,0 +1,101 @@ +package xgmext + +import ( + "errors" + "fmt" + + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/models" + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/client" + "github.com/emersion/go-imap/commands" + "github.com/emersion/go-imap/responses" +) + +type handler struct { + client *client.Client +} + +func NewHandler(c *client.Client) *handler { + return &handler{client: c} +} + +func (h handler) FetchEntireThreads(requested []models.UID) ([]models.UID, error) { + threadIds, err := h.fetchThreadIds(requested) + if err != nil { + return nil, + fmt.Errorf("failed to fetch thread IDs: %w", err) + } + uids, err := h.searchUids(threadIds) + if err != nil { + return nil, + fmt.Errorf("failed to search for thread IDs: %w", err) + } + return uids, nil +} + +func (h handler) fetchThreadIds(uids []models.UID) ([]string, error) { + messages := make(chan *imap.Message) + done := make(chan error) + + thriditem := imap.FetchItem("X-GM-THRID") + items := []imap.FetchItem{ + thriditem, + } + + m := make(map[string]struct{}, len(uids)) + go func() { + defer log.PanicHandler() + for msg := range messages { + if msg == nil { + continue + } + item, ok := msg.Items[thriditem].(string) + if ok { + m[item] = struct{}{} + } + } + done <- nil + }() + + var set imap.SeqSet + for _, uid := range uids { + set.AddNum(models.UidToUint32(uid)) + } + err := h.client.UidFetch(&set, items, messages) + <-done + + thrid := make([]string, 0, len(m)) + for id := range m { + thrid = append(thrid, id) + } + return thrid, err +} + +func (h handler) searchUids(thrid []string) ([]models.UID, error) { + if len(thrid) == 0 { + return nil, errors.New("no thread IDs provided") + } + return h.runSearch(NewThreadIDSearch(thrid)) +} + +func (h handler) RawSearch(rawSearch string) ([]models.UID, error) { + return h.runSearch(NewRawSearch(rawSearch)) +} + +func (h handler) runSearch(cmd imap.Commander) ([]models.UID, error) { + if h.client.State() != imap.SelectedState { + return nil, errors.New("no mailbox selected") + } + cmd = &commands.Uid{Cmd: cmd} + res := new(responses.Search) + status, err := h.client.Execute(cmd, res) + if err != nil { + return nil, fmt.Errorf("imap execute failed: %w", err) + } + var uids []models.UID + for _, i := range res.Ids { + uids = append(uids, models.Uint32ToUid(i)) + } + return uids, status.Err() +} diff --git a/worker/imap/extensions/xgmext/search.go b/worker/imap/extensions/xgmext/search.go new file mode 100644 index 0000000..42a4f2c --- /dev/null +++ b/worker/imap/extensions/xgmext/search.go @@ -0,0 +1,74 @@ +package xgmext + +import "github.com/emersion/go-imap" + +type threadIDSearch struct { + Charset string + ThreadIDs []string +} + +// NewThreadIDSearch return an imap.Command to search UIDs for the provided +// thread IDs using the X-GM-EXT-1 (Gmail extension) +func NewThreadIDSearch(threadIDs []string) *threadIDSearch { + return &threadIDSearch{ + Charset: "UTF-8", + ThreadIDs: threadIDs, + } +} + +func (cmd *threadIDSearch) Command() *imap.Command { + const threadSearchKey = "X-GM-THRID" + + var args []interface{} + if cmd.Charset != "" { + args = append(args, imap.RawString("CHARSET")) + args = append(args, imap.RawString(cmd.Charset)) + } + + // we want to produce a search query that looks like this: + // SEARCH CHARSET UTF-8 OR OR X-GM-THRID 1771431779961568536 \ + // X-GM-THRID 1765355745646219617 X-GM-THRID 1771500774375286796 + for i := 0; i < len(cmd.ThreadIDs)-1; i++ { + args = append(args, imap.RawString("OR")) + } + + for _, thrid := range cmd.ThreadIDs { + args = append(args, imap.RawString(threadSearchKey)) + args = append(args, imap.RawString(thrid)) + } + + return &imap.Command{ + Name: "SEARCH", + Arguments: args, + } +} + +type rawSearch struct { + Charset string + Search string +} + +func NewRawSearch(search string) *rawSearch { + return &rawSearch{ + Charset: "UTF-8", + Search: search, + } +} + +func (cmd *rawSearch) Command() *imap.Command { + const key = "X-GM-RAW" + + var args []interface{} + if cmd.Charset != "" { + args = append(args, imap.RawString("CHARSET")) + args = append(args, imap.RawString(cmd.Charset)) + } + + args = append(args, imap.RawString(key)) + args = append(args, imap.RawString(cmd.Search)) + + return &imap.Command{ + Name: "SEARCH", + Arguments: args, + } +} diff --git a/worker/imap/extensions/xgmext/search_test.go b/worker/imap/extensions/xgmext/search_test.go new file mode 100644 index 0000000..a2a2791 --- /dev/null +++ b/worker/imap/extensions/xgmext/search_test.go @@ -0,0 +1,76 @@ +package xgmext_test + +import ( + "bytes" + "testing" + + "git.sr.ht/~rjarry/aerc/worker/imap/extensions/xgmext" + "github.com/emersion/go-imap" +) + +func TestXGMEXT_ThreadIDSearch(t *testing.T) { + tests := []struct { + name string + ids []string + want string + }{ + { + name: "search for single id", + ids: []string{"1234"}, + want: "* SEARCH CHARSET UTF-8 X-GM-THRID 1234\r\n", + }, + { + name: "search for multiple id", + ids: []string{"1234", "5678", "2345"}, + want: "* SEARCH CHARSET UTF-8 OR OR X-GM-THRID 1234 X-GM-THRID 5678 X-GM-THRID 2345\r\n", + }, + } + for _, test := range tests { + cmd := xgmext.NewThreadIDSearch(test.ids).Command() + var buf bytes.Buffer + err := cmd.WriteTo(imap.NewWriter(&buf)) + if err != nil { + t.Errorf("failed to write command: %v", err) + } + if got := buf.String(); got != test.want { + t.Errorf("test '%s' failed: got: '%s', but wanted: '%s'", + test.name, got, test.want) + } + } +} + +func TestXGMEXT_RawSearch(t *testing.T) { + tests := []struct { + name string + search string + want string + }{ + { + name: "search messages from mailing list", + search: "list:info@example.com", + want: "* SEARCH CHARSET UTF-8 X-GM-RAW list:info@example.com\r\n", + }, + { + name: "search for an exact phrase", + search: "\"good morning\"", + want: "* SEARCH CHARSET UTF-8 X-GM-RAW \"good morning\"\r\n", + }, + { + name: "group multiple search terms together", + search: "subject:(dinner movie)", + want: "* SEARCH CHARSET UTF-8 X-GM-RAW subject:(dinner movie)\r\n", + }, + } + for _, test := range tests { + cmd := xgmext.NewRawSearch(test.search).Command() + var buf bytes.Buffer + err := cmd.WriteTo(imap.NewWriter(&buf)) + if err != nil { + t.Errorf("failed to write command: %v", err) + } + if got := buf.String(); got != test.want { + t.Errorf("test '%s' failed: got: '%s', but wanted: '%s'", + test.name, got, test.want) + } + } +} diff --git a/worker/imap/extensions/xgmext/terms.go b/worker/imap/extensions/xgmext/terms.go new file mode 100644 index 0000000..b7dcfd3 --- /dev/null +++ b/worker/imap/extensions/xgmext/terms.go @@ -0,0 +1,46 @@ +package xgmext + +var Terms = []string{ + "from:", + "to:", + "cc:", + "bcc:", + "subject:", + "label:", + "deliveredto:", + "category:primary", + "category:social", + "category:promotions", + "category:updates", + "category:forums", + "category:reservations", + "category:purchases", + "has:", + "has:attachment", + "has:drive", + "has:document", + "has:spreadsheet", + "has:presentation", + "has:youtube", + "list:", + "filename:", + "in:", + "is:", + "is:important", + "is:read", + "is:unread", + "is:starred", + "after:", + "before:", + "older:", + "newer:", + "older_than:", + "newer_than:", + "size:", + "larger:", + "smaller:", + "rfc822msgid:", + "OR", + "AND", + "AROUND", +} diff --git a/worker/imap/fetch.go b/worker/imap/fetch.go new file mode 100644 index 0000000..9b77c77 --- /dev/null +++ b/worker/imap/fetch.go @@ -0,0 +1,292 @@ +package imap + +import ( + "bufio" + "fmt" + + "github.com/emersion/go-imap" + "github.com/emersion/go-message" + _ "github.com/emersion/go-message/charset" + "github.com/emersion/go-message/mail" + "github.com/emersion/go-message/textproto" + + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/parse" + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/types" +) + +func (imapw *IMAPWorker) handleFetchMessageHeaders( + msg *types.FetchMessageHeaders, +) { + if msg.Context.Err() != nil { + imapw.worker.PostMessage(&types.Cancelled{ + Message: types.RespondTo(msg), + }, nil) + return + } + toFetch := msg.Uids + if imapw.config.cacheEnabled && imapw.cache != nil { + toFetch = imapw.getCachedHeaders(msg) + } + if len(toFetch) == 0 { + imapw.worker.PostMessage(&types.Done{Message: types.RespondTo(msg)}, + nil) + return + } + imapw.worker.Tracef("Fetching message headers: %v", toFetch) + hdrBodyPart := imap.BodyPartName{ + Specifier: imap.HeaderSpecifier, + } + switch { + case len(imapw.config.headersExclude) > 0: + hdrBodyPart.NotFields = true + hdrBodyPart.Fields = imapw.config.headersExclude + case len(imapw.config.headers) > 0: + hdrBodyPart.Fields = imapw.config.headers + } + section := &imap.BodySectionName{ + BodyPartName: hdrBodyPart, + Peek: true, + } + + items := []imap.FetchItem{ + imap.FetchBodyStructure, + imap.FetchEnvelope, + imap.FetchInternalDate, + imap.FetchFlags, + imap.FetchUid, + imap.FetchRFC822Size, + section.FetchItem(), + } + imapw.handleFetchMessages(msg, toFetch, items, + func(_msg *imap.Message) error { + if len(_msg.Body) == 0 { + // ignore duplicate messages with only flag updates + return nil + } + reader := _msg.GetBody(section) + if reader == nil { + return fmt.Errorf("failed to find part: %v", section) + } + textprotoHeader, err := textproto.ReadHeader(bufio.NewReader(reader)) + if err != nil { + return fmt.Errorf("failed to read part header: %w", err) + } + header := &mail.Header{Header: message.Header{Header: textprotoHeader}} + info := &models.MessageInfo{ + BodyStructure: translateBodyStructure(_msg.BodyStructure), + Envelope: translateEnvelope(_msg.Envelope), + Flags: translateImapFlags(_msg.Flags), + InternalDate: _msg.InternalDate, + RFC822Headers: header, + Refs: parse.MsgIDList(header, "references"), + Size: _msg.Size, + Uid: models.Uint32ToUid(_msg.Uid), + } + imapw.worker.PostMessage(&types.MessageInfo{ + Message: types.RespondTo(msg), + Info: info, + }, nil) + if imapw.config.cacheEnabled && imapw.cache != nil { + imapw.cacheHeader(info) + } + return nil + }) +} + +func (imapw *IMAPWorker) handleFetchMessageBodyPart( + msg *types.FetchMessageBodyPart, +) { + imapw.worker.Tracef("Fetching message %d part: %v", msg.Uid, msg.Part) + + var partHeaderSection imap.BodySectionName + partHeaderSection.Peek = true + if len(msg.Part) > 0 { + partHeaderSection.Specifier = imap.MIMESpecifier + } else { + partHeaderSection.Specifier = imap.HeaderSpecifier + } + partHeaderSection.Path = msg.Part + + var partBodySection imap.BodySectionName + if len(msg.Part) > 0 { + partBodySection.Specifier = imap.EntireSpecifier + } else { + partBodySection.Specifier = imap.TextSpecifier + } + partBodySection.Path = msg.Part + partBodySection.Peek = true + + items := []imap.FetchItem{ + imap.FetchEnvelope, + imap.FetchUid, + imap.FetchBodyStructure, + imap.FetchFlags, + partHeaderSection.FetchItem(), + partBodySection.FetchItem(), + } + imapw.handleFetchMessages(msg, []models.UID{msg.Uid}, items, + func(_msg *imap.Message) error { + if len(_msg.Body) == 0 { + // ignore duplicate messages with only flag updates + return nil + } + body := _msg.GetBody(&partHeaderSection) + if body == nil { + return fmt.Errorf("failed to find part: %v", partHeaderSection) + } + h, err := textproto.ReadHeader(bufio.NewReader(body)) + if err != nil { + return fmt.Errorf("failed to read part header: %w", err) + } + + part, err := message.New(message.Header{Header: h}, + _msg.GetBody(&partBodySection)) + if message.IsUnknownCharset(err) { + imapw.worker.Warnf("unknown charset encountered "+ + "for uid %d", _msg.Uid) + } else if err != nil { + return fmt.Errorf("failed to create message reader: %w", err) + } + + imapw.worker.PostMessage(&types.MessageBodyPart{ + Message: types.RespondTo(msg), + Part: &models.MessageBodyPart{ + Reader: part.Body, + Uid: models.Uint32ToUid(_msg.Uid), + }, + }, nil) + // Update flags (to mark message as read) + imapw.worker.PostMessage(&types.MessageInfo{ + Message: types.RespondTo(msg), + Info: &models.MessageInfo{ + Flags: translateImapFlags(_msg.Flags), + Uid: models.Uint32ToUid(_msg.Uid), + }, + }, nil) + return nil + }) +} + +func (imapw *IMAPWorker) handleFetchFullMessages( + msg *types.FetchFullMessages, +) { + imapw.worker.Tracef("Fetching full messages: %v", msg.Uids) + section := &imap.BodySectionName{ + Peek: true, + } + items := []imap.FetchItem{ + imap.FetchEnvelope, + imap.FetchFlags, + imap.FetchUid, + section.FetchItem(), + } + imapw.handleFetchMessages(msg, msg.Uids, items, + func(_msg *imap.Message) error { + if len(_msg.Body) == 0 { + // ignore duplicate messages with only flag updates + return nil + } + r := _msg.GetBody(section) + if r == nil { + return fmt.Errorf("could not get section %#v", section) + } + imapw.worker.PostMessage(&types.FullMessage{ + Message: types.RespondTo(msg), + Content: &models.FullMessage{ + Reader: bufio.NewReader(r), + Uid: models.Uint32ToUid(_msg.Uid), + }, + }, nil) + // Update flags (to mark message as read) + imapw.worker.PostMessage(&types.MessageInfo{ + Message: types.RespondTo(msg), + Info: &models.MessageInfo{ + Flags: translateImapFlags(_msg.Flags), + Uid: models.Uint32ToUid(_msg.Uid), + }, + }, nil) + return nil + }) +} + +func (imapw *IMAPWorker) handleFetchMessageFlags(msg *types.FetchMessageFlags) { + items := []imap.FetchItem{ + imap.FetchFlags, + imap.FetchUid, + } + if msg.Context.Err() != nil { + imapw.worker.PostMessage(&types.Cancelled{ + Message: types.RespondTo(msg), + }, nil) + return + } + imapw.handleFetchMessages(msg, msg.Uids, items, + func(_msg *imap.Message) error { + imapw.worker.PostMessage(&types.MessageInfo{ + Message: types.RespondTo(msg), + Info: &models.MessageInfo{ + Flags: translateImapFlags(_msg.Flags), + Uid: models.Uint32ToUid(_msg.Uid), + }, + }, nil) + return nil + }) +} + +func (imapw *IMAPWorker) handleFetchMessages( + msg types.WorkerMessage, uids []models.UID, items []imap.FetchItem, + procFunc func(*imap.Message) error, +) { + messages := make(chan *imap.Message) + done := make(chan struct{}) + + missingUids := make(map[models.UID]bool) + for _, uid := range uids { + missingUids[uid] = true + } + + go func() { + defer log.PanicHandler() + + for _msg := range messages { + delete(missingUids, models.Uint32ToUid(_msg.Uid)) + err := procFunc(_msg) + if err != nil { + log.Errorf("failed to process message <%d>: %v", _msg.Uid, err) + imapw.worker.PostMessage(&types.MessageInfo{ + Message: types.RespondTo(msg), + Info: &models.MessageInfo{ + Uid: models.Uint32ToUid(_msg.Uid), + Error: err, + }, + }, nil) + } + } + close(done) + }() + + set := toSeqSet(uids) + if err := imapw.client.UidFetch(set, items, messages); err != nil { + imapw.worker.PostMessage(&types.Error{ + Message: types.RespondTo(msg), + Error: err, + }, nil) + return + } + <-done + + for uid := range missingUids { + imapw.worker.PostMessage(&types.MessageInfo{ + Message: types.RespondTo(msg), + Info: &models.MessageInfo{ + Uid: uid, + Error: fmt.Errorf("invalid response from server (detailed error in log)"), + }, + }, nil) + } + + imapw.worker.PostMessage( + &types.Done{Message: types.RespondTo(msg)}, nil) +} diff --git a/worker/imap/flags.go b/worker/imap/flags.go new file mode 100644 index 0000000..31d3bea --- /dev/null +++ b/worker/imap/flags.go @@ -0,0 +1,156 @@ +package imap + +import ( + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/client" + + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/types" +) + +// drainUpdates will drain the updates channel. For some operations, the imap +// server will send unilateral messages. If they arrive while another operation +// is in progress, the buffered updates channel can fill up and cause a freeze +// of the entire backend. Avoid this by draining the updates channel and only +// process the Message and Expunge updates. +// +// To stop the draining, close the returned struct. +func (imapw *IMAPWorker) drainUpdates() *drainCloser { + done := make(chan struct{}) + go func() { + defer log.PanicHandler() + for { + select { + case update := <-imapw.updates: + switch update.(type) { + case *client.MessageUpdate, + *client.ExpungeUpdate: + imapw.handleImapUpdate(update) + } + case <-done: + return + } + } + }() + return &drainCloser{done} +} + +type drainCloser struct { + done chan struct{} +} + +func (d *drainCloser) Close() error { + close(d.done) + return nil +} + +func (imapw *IMAPWorker) handleDeleteMessages(msg *types.DeleteMessages) { + drain := imapw.drainUpdates() + defer drain.Close() + + item := imap.FormatFlagsOp(imap.AddFlags, true) + flags := []interface{}{imap.DeletedFlag} + uids := toSeqSet(msg.Uids) + if err := imapw.client.UidStore(uids, item, flags, nil); err != nil { + imapw.worker.PostMessage(&types.Error{ + Message: types.RespondTo(msg), + Error: err, + }, nil) + return + } + if err := imapw.client.Expunge(nil); err != nil { + imapw.worker.PostMessage(&types.Error{ + Message: types.RespondTo(msg), + Error: err, + }, nil) + } else { + imapw.worker.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil) + } +} + +func (imapw *IMAPWorker) handleAnsweredMessages(msg *types.AnsweredMessages) { + item := imap.FormatFlagsOp(imap.AddFlags, false) + flags := []interface{}{imap.AnsweredFlag} + if !msg.Answered { + item = imap.FormatFlagsOp(imap.RemoveFlags, false) + } + imapw.handleStoreOps(msg, msg.Uids, item, flags, + func(_msg *imap.Message) error { + imapw.worker.PostMessage(&types.MessageInfo{ + Message: types.RespondTo(msg), + Info: &models.MessageInfo{ + Flags: translateImapFlags(_msg.Flags), + Uid: models.Uint32ToUid(_msg.Uid), + }, + }, nil) + return nil + }) +} + +func (imapw *IMAPWorker) handleFlagMessages(msg *types.FlagMessages) { + flags := []interface{}{flagToImap[msg.Flags]} + item := imap.FormatFlagsOp(imap.AddFlags, false) + if !msg.Enable { + item = imap.FormatFlagsOp(imap.RemoveFlags, false) + } + imapw.handleStoreOps(msg, msg.Uids, item, flags, + func(_msg *imap.Message) error { + imapw.worker.PostMessage(&types.MessageInfo{ + Message: types.RespondTo(msg), + Info: &models.MessageInfo{ + Flags: translateImapFlags(_msg.Flags), + Uid: models.Uint32ToUid(_msg.Uid), + }, + }, nil) + return nil + }) +} + +func (imapw *IMAPWorker) handleStoreOps( + msg types.WorkerMessage, uids []models.UID, item imap.StoreItem, flag interface{}, + procFunc func(*imap.Message) error, +) { + messages := make(chan *imap.Message) + done := make(chan error) + + go func() { + defer log.PanicHandler() + + var reterr error + for _msg := range messages { + err := procFunc(_msg) + if err != nil { + if reterr == nil { + reterr = err + } + // drain the channel upon error + for range messages { + } + } + } + done <- reterr + }() + + emitErr := func(err error) { + imapw.worker.PostMessage(&types.Error{ + Message: types.RespondTo(msg), + Error: err, + }, nil) + } + + set := toSeqSet(uids) + if err := imapw.client.UidStore(set, item, flag, messages); err != nil { + emitErr(err) + return + } + if err := <-done; err != nil { + emitErr(err) + return + } + imapw.worker.PostAction(&types.CheckMail{ + Directories: []string{imapw.selected.Name}, + }, nil) + imapw.worker.PostMessage( + &types.Done{Message: types.RespondTo(msg)}, nil) +} diff --git a/worker/imap/idler.go b/worker/imap/idler.go new file mode 100644 index 0000000..b43c026 --- /dev/null +++ b/worker/imap/idler.go @@ -0,0 +1,132 @@ +package imap + +import ( + "fmt" + "time" + + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/worker/types" + "github.com/emersion/go-imap" +) + +var errIdleTimeout = fmt.Errorf("idle timeout") + +// idler manages the idle mode of the imap server. Enter idle mode if there's +// no other task and leave idle mode when a new task arrives. Idle mode is only +// used when the client is ready and connected. After a connection loss, make +// sure that idling returns gracefully and the worker remains responsive. +type idler struct { + client *imapClient + debouncer *time.Timer + debounce time.Duration + timeout time.Duration + worker types.WorkerInteractor + stop chan struct{} + start chan struct{} + done chan error +} + +func newIdler(cfg imapConfig, w types.WorkerInteractor, startIdler chan struct{}) *idler { + return &idler{ + debouncer: nil, + debounce: cfg.idle_debounce, + timeout: cfg.idle_timeout, + worker: w, + stop: make(chan struct{}), + start: startIdler, + done: make(chan error), + } +} + +func (i *idler) SetClient(c *imapClient) { + i.client = c +} + +func (i *idler) ready() bool { + return (i.client != nil && i.client.State() == imap.SelectedState) +} + +func (i *idler) Start() { + if !i.ready() { + return + } + + select { + case <-i.stop: + // stop channel is nil (probably after a debounce), we don't + // want to close it + default: + close(i.stop) + } + + // create new stop channel + i.stop = make(chan struct{}) + + // clear done channel + clearing := true + for clearing { + select { + case <-i.done: + continue + default: + clearing = false + } + } + + i.worker.Tracef("idler (start): start idle after debounce") + i.debouncer = time.AfterFunc(i.debounce, func() { + i.start <- struct{}{} + i.worker.Tracef("idler (start): started") + }) +} + +func (i *idler) Execute() { + if !i.ready() { + return + } + + // we need to call client.Idle in a goroutine since it is blocking call + // and we still want to receive messages + go func() { + defer log.PanicHandler() + + start := time.Now() + err := i.client.Idle(i.stop, nil) + if err != nil { + i.worker.Errorf("idle returned error: %v", err) + } + i.worker.Tracef("idler (execute): idleing for %s", time.Since(start)) + + i.done <- err + }() +} + +func (i *idler) Stop() error { + if !i.ready() { + return nil + } + + select { + case <-i.stop: + i.worker.Debugf("idler (stop): idler already stopped?") + return nil + default: + close(i.stop) + } + + if i.debouncer != nil { + if i.debouncer.Stop() { + i.worker.Tracef("idler (stop): debounced") + return nil + } + } + + select { + case err := <-i.done: + i.worker.Tracef("idler (stop): idle stopped: %v", err) + return err + case <-time.After(i.timeout): + i.worker.Errorf("idler (stop): cannot stop idle (timeout)") + return errIdleTimeout + } +} diff --git a/worker/imap/imap.go b/worker/imap/imap.go new file mode 100644 index 0000000..9b6316b --- /dev/null +++ b/worker/imap/imap.go @@ -0,0 +1,119 @@ +package imap + +import ( + "strings" + + "github.com/emersion/go-imap" + + "git.sr.ht/~rjarry/aerc/models" + "github.com/emersion/go-message/charset" + "github.com/emersion/go-message/mail" +) + +func init() { + imap.CharsetReader = charset.Reader +} + +func toSeqSet(uids []models.UID) *imap.SeqSet { + set := new(imap.SeqSet) + for _, uid := range uids { + set.AddNum(models.UidToUint32(uid)) + } + return set +} + +func translateBodyStructure(bs *imap.BodyStructure) *models.BodyStructure { + if bs == nil { + return nil + } + var parts []*models.BodyStructure + for _, part := range bs.Parts { + parts = append(parts, translateBodyStructure(part)) + } + + // TODO: is that all? + + return &models.BodyStructure{ + MIMEType: bs.MIMEType, + MIMESubType: bs.MIMESubType, + Params: bs.Params, + Description: bs.Description, + Encoding: bs.Encoding, + Parts: parts, + Disposition: bs.Disposition, + DispositionParams: bs.DispositionParams, + } +} + +func translateEnvelope(e *imap.Envelope) *models.Envelope { + if e == nil { + return nil + } + + return &models.Envelope{ + Date: e.Date, + Subject: e.Subject, + From: translateAddresses(e.From), + ReplyTo: translateAddresses(e.ReplyTo), + To: translateAddresses(e.To), + Cc: translateAddresses(e.Cc), + Bcc: translateAddresses(e.Bcc), + MessageId: translateMessageID(e.MessageId), + InReplyTo: translateMessageID(e.InReplyTo), + } +} + +func translateMessageID(messageID string) string { + // Strip away unwanted characters, go-message expects the message id + // without brackets, spaces, tabs and new lines. + return strings.Trim(messageID, "<> \t\r\n") +} + +func translateAddresses(addrs []*imap.Address) []*mail.Address { + var converted []*mail.Address + for _, addr := range addrs { + converted = append(converted, &mail.Address{ + Name: addr.PersonalName, + Address: addr.Address(), + }) + } + return converted +} + +var imapToFlag = map[string]models.Flags{ + imap.SeenFlag: models.SeenFlag, + imap.RecentFlag: models.RecentFlag, + imap.AnsweredFlag: models.AnsweredFlag, + imap.DeletedFlag: models.DeletedFlag, + imap.FlaggedFlag: models.FlaggedFlag, + imap.DraftFlag: models.DraftFlag, +} + +var flagToImap = map[models.Flags]string{ + models.SeenFlag: imap.SeenFlag, + models.RecentFlag: imap.RecentFlag, + models.AnsweredFlag: imap.AnsweredFlag, + models.DeletedFlag: imap.DeletedFlag, + models.FlaggedFlag: imap.FlaggedFlag, + models.DraftFlag: imap.DraftFlag, +} + +func translateImapFlags(imapFlags []string) models.Flags { + var flags models.Flags + for _, imapFlag := range imapFlags { + if flag, ok := imapToFlag[imapFlag]; ok { + flags |= flag + } + } + return flags +} + +func translateFlags(flags models.Flags) []string { + var imapFlags []string + for flag, imapFlag := range flagToImap { + if flags.Has(flag) { + imapFlags = append(imapFlags, imapFlag) + } + } + return imapFlags +} diff --git a/worker/imap/imap_test.go b/worker/imap/imap_test.go new file mode 100644 index 0000000..6a0615c --- /dev/null +++ b/worker/imap/imap_test.go @@ -0,0 +1,51 @@ +package imap + +import ( + "testing" + "time" + + "git.sr.ht/~rjarry/aerc/models" + "github.com/emersion/go-message/mail" + + "github.com/emersion/go-imap" + "github.com/stretchr/testify/assert" +) + +func TestTranslateEnvelope(t *testing.T) { + date, _ := time.Parse("2010-01-31", "1992-10-24") + givenAddress := imap.Address{ + PersonalName: "PERSONAL_NAME", + AtDomainList: "AT_DOMAIN_LIST", + MailboxName: "MAILBOX_NAME", + HostName: "HOST_NAME", + } + givenMessageID := " \r\n\r \t <initial-message-id@with-leading-space>\t\r" + given := imap.Envelope{ + Date: date, + Subject: "Test Subject", + From: []*imap.Address{&givenAddress}, + ReplyTo: []*imap.Address{&givenAddress}, + To: []*imap.Address{&givenAddress}, + Cc: []*imap.Address{&givenAddress}, + Bcc: []*imap.Address{&givenAddress}, + MessageId: givenMessageID, + InReplyTo: givenMessageID, + } + expectedMessageID := "initial-message-id@with-leading-space" + expectedAddress := mail.Address{ + Name: "PERSONAL_NAME", + Address: "MAILBOX_NAME@HOST_NAME", + } + expected := models.Envelope{ + Date: date, + Subject: "Test Subject", + From: []*mail.Address{&expectedAddress}, + ReplyTo: []*mail.Address{&expectedAddress}, + To: []*mail.Address{&expectedAddress}, + Cc: []*mail.Address{&expectedAddress}, + Bcc: []*mail.Address{&expectedAddress}, + MessageId: expectedMessageID, + InReplyTo: expectedMessageID, + } + assert.Equal(t, &expected, translateEnvelope(&given)) +} diff --git a/worker/imap/list.go b/worker/imap/list.go new file mode 100644 index 0000000..e3c9db3 --- /dev/null +++ b/worker/imap/list.go @@ -0,0 +1,144 @@ +package imap + +import ( + "strings" + + "github.com/emersion/go-imap" + + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/types" +) + +func (imapw *IMAPWorker) handleListDirectories(msg *types.ListDirectories) { + mailboxes := make(chan *imap.MailboxInfo) + imapw.worker.Tracef("Listing mailboxes") + done := make(chan interface{}) + + go func() { + defer log.PanicHandler() + + for mbox := range mailboxes { + if !canOpen(mbox) { + // no need to pass this to handlers if it can't be opened + continue + } + dir := &models.Directory{ + Name: mbox.Name, + } + for _, attr := range mbox.Attributes { + attr = strings.TrimPrefix(attr, "\\") + attr = strings.ToLower(attr) + role, ok := models.Roles[attr] + if !ok { + continue + } + dir.Role = role + } + if mbox.Name == "INBOX" { + dir.Role = models.InboxRole + } + imapw.worker.PostMessage(&types.Directory{ + Message: types.RespondTo(msg), + Dir: dir, + }, nil) + } + done <- nil + }() + + switch { + case imapw.liststatus: + items := []imap.StatusItem{ + imap.StatusMessages, + imap.StatusRecent, + imap.StatusUnseen, + } + statuses, err := imapw.client.liststatus.ListStatus( + "", + "*", + items, + mailboxes, + ) + if err != nil { + <-done + imapw.worker.PostMessage(&types.Error{ + Message: types.RespondTo(msg), + Error: err, + }, nil) + return + + } + for _, status := range statuses { + imapw.worker.PostMessage(&types.DirectoryInfo{ + Info: &models.DirectoryInfo{ + Name: status.Name, + Exists: int(status.Messages), + Recent: int(status.Recent), + Unseen: int(status.Unseen), + }, + }, nil) + } + default: + err := imapw.client.List("", "*", mailboxes) + if err != nil { + <-done + imapw.worker.PostMessage(&types.Error{ + Message: types.RespondTo(msg), + Error: err, + }, nil) + return + } + } + <-done + imapw.worker.PostMessage( + &types.Done{Message: types.RespondTo(msg)}, nil) +} + +const NonExistentAttr = "\\NonExistent" + +func canOpen(mbox *imap.MailboxInfo) bool { + for _, attr := range mbox.Attributes { + if attr == imap.NoSelectAttr || + attr == NonExistentAttr { + return false + } + } + return true +} + +func (imapw *IMAPWorker) handleSearchDirectory(msg *types.SearchDirectory) { + emitError := func(err error) { + imapw.worker.PostMessage(&types.Error{ + Message: types.RespondTo(msg), + Error: err, + }, nil) + } + + imapw.worker.Tracef("Executing search") + criteria := translateSearch(msg.Criteria) + + if msg.Context.Err() != nil { + imapw.worker.PostMessage(&types.Cancelled{ + Message: types.RespondTo(msg), + }, nil) + return + } + + uids, err := imapw.client.UidSearch(criteria) + if err != nil { + emitError(err) + return + } + + if msg.Context.Err() != nil { + imapw.worker.PostMessage(&types.Cancelled{ + Message: types.RespondTo(msg), + }, nil) + return + } + + imapw.worker.PostMessage(&types.SearchResults{ + Message: types.RespondTo(msg), + Uids: models.Uint32ToUidList(uids), + }, nil) +} diff --git a/worker/imap/movecopy.go b/worker/imap/movecopy.go new file mode 100644 index 0000000..58357a4 --- /dev/null +++ b/worker/imap/movecopy.go @@ -0,0 +1,68 @@ +package imap + +import ( + "io" + + "git.sr.ht/~rjarry/aerc/worker/types" +) + +func (imapw *IMAPWorker) handleCopyMessages(msg *types.CopyMessages) { + uids := toSeqSet(msg.Uids) + if err := imapw.client.UidCopy(uids, msg.Destination); err != nil { + imapw.worker.PostMessage(&types.Error{ + Message: types.RespondTo(msg), + Error: err, + }, nil) + } else { + imapw.worker.PostMessage(&types.MessagesCopied{ + Message: types.RespondTo(msg), + Destination: msg.Destination, + Uids: msg.Uids, + }, nil) + imapw.worker.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil) + } +} + +type appendLiteral struct { + io.Reader + Length int +} + +func (m appendLiteral) Len() int { + return m.Length +} + +func (imapw *IMAPWorker) handleAppendMessage(msg *types.AppendMessage) { + if err := imapw.client.Append(msg.Destination, translateFlags(msg.Flags), msg.Date, + &appendLiteral{ + Reader: msg.Reader, + Length: msg.Length, + }); err != nil { + imapw.worker.PostMessage(&types.Error{ + Message: types.RespondTo(msg), + Error: err, + }, nil) + } else { + imapw.worker.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil) + } +} + +func (imapw *IMAPWorker) handleMoveMessages(msg *types.MoveMessages) { + drain := imapw.drainUpdates() + defer drain.Close() + + uids := toSeqSet(msg.Uids) + if err := imapw.client.UidMove(uids, msg.Destination); err != nil { + imapw.worker.PostMessage(&types.Error{ + Message: types.RespondTo(msg), + Error: err, + }, nil) + } else { + imapw.worker.PostMessage(&types.MessagesMoved{ + Message: types.RespondTo(msg), + Destination: msg.Destination, + Uids: msg.Uids, + }, nil) + imapw.worker.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil) + } +} diff --git a/worker/imap/observer.go b/worker/imap/observer.go new file mode 100644 index 0000000..5bc434f --- /dev/null +++ b/worker/imap/observer.go @@ -0,0 +1,149 @@ +package imap + +import ( + "fmt" + "math" + "sync" + "time" + + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/worker/types" + "github.com/emersion/go-imap" +) + +// observer monitors the loggedOut channel of the imap client. If the logout +// signal is received, the observer will emit a connection error to the ui in +// order to start the reconnect cycle. +type observer struct { + sync.Mutex + config imapConfig + client *imapClient + worker types.WorkerInteractor + done chan struct{} + autoReconnect bool + retries int + running bool +} + +func newObserver(cfg imapConfig, w types.WorkerInteractor) *observer { + return &observer{config: cfg, worker: w, done: make(chan struct{})} +} + +func (o *observer) SetClient(c *imapClient) { + o.Stop() + o.Lock() + o.client = c + o.Unlock() + o.Start() + o.retries = 0 +} + +func (o *observer) SetAutoReconnect(auto bool) { + o.autoReconnect = auto +} + +func (o *observer) AutoReconnect() bool { + return o.autoReconnect +} + +func (o *observer) isClientConnected() bool { + o.Lock() + defer o.Unlock() + return o.client != nil && o.client.State() == imap.SelectedState +} + +func (o *observer) EmitIfNotConnected() bool { + if !o.isClientConnected() { + o.emit("imap client not connected: attempt reconnect") + return true + } + return false +} + +func (o *observer) IsRunning() bool { + return o.running +} + +func (o *observer) Start() { + if o.running { + return + } + if o.client == nil { + return + } + if o.EmitIfNotConnected() { + return + } + go func() { + defer log.PanicHandler() + select { + case <-o.client.LoggedOut(): + if o.autoReconnect { + o.emit("logged out") + } else { + o.log("ignore logout (auto-reconnect off)") + } + case <-o.done: + break + } + o.running = false + o.log("stopped") + }() + o.running = true + o.log("started") +} + +func (o *observer) Stop() { + if o.client == nil { + return + } + if o.done != nil { + close(o.done) + } + o.done = make(chan struct{}) + o.running = false +} + +func (o *observer) DelayedReconnect() error { + var wait time.Duration + var reterr error + + if o.retries > 0 { + backoff := int(math.Pow(1.8, float64(o.retries))) + var err error + wait, err = time.ParseDuration(fmt.Sprintf("%ds", backoff)) + if err != nil { + return err + } + if wait > o.config.reconnect_maxwait { + wait = o.config.reconnect_maxwait + } + + reterr = fmt.Errorf("reconnect in %v", wait) + } else { + reterr = fmt.Errorf("reconnect") + } + + go func() { + defer log.PanicHandler() + <-time.After(wait) + o.emit(reterr.Error()) + }() + + o.retries++ + return reterr +} + +func (o *observer) emit(errMsg string) { + o.worker.PostMessage(&types.Done{ + Message: types.RespondTo(&types.Disconnect{}), + }, nil) + o.worker.PostMessage(&types.ConnError{ + Error: fmt.Errorf("%s", errMsg), + }, nil) +} + +func (o *observer) log(format string, args ...interface{}) { + msg := fmt.Sprintf(format, args...) + o.worker.Tracef("observer (%p) [running:%t] %s", o, o.running, msg) +} diff --git a/worker/imap/open.go b/worker/imap/open.go new file mode 100644 index 0000000..b1314a4 --- /dev/null +++ b/worker/imap/open.go @@ -0,0 +1,203 @@ +package imap + +import ( + "sort" + + sortthread "github.com/emersion/go-imap-sortthread" + + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/types" +) + +func (imapw *IMAPWorker) handleOpenDirectory(msg *types.OpenDirectory) { + imapw.worker.Debugf("Opening %s", msg.Directory) + + sel, err := imapw.client.Select(msg.Directory, false) + if err != nil { + imapw.worker.PostMessage(&types.Error{ + Message: types.RespondTo(msg), + Error: err, + }, nil) + return + } + select { + case <-msg.Context.Done(): + imapw.worker.PostMessage(&types.Cancelled{Message: types.RespondTo(msg)}, nil) + default: + imapw.selected = sel + imapw.worker.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil) + } +} + +func (imapw *IMAPWorker) handleFetchDirectoryContents( + msg *types.FetchDirectoryContents, +) { + if msg.Context.Err() != nil { + imapw.worker.PostMessage(&types.Cancelled{ + Message: types.RespondTo(msg), + }, nil) + return + } + imapw.worker.Tracef("Fetching UID list") + + searchCriteria := translateSearch(msg.Filter) + sortCriteria := translateSortCriterions(msg.SortCriteria) + hasSortCriteria := len(sortCriteria) > 0 + + var err error + var uids []uint32 + + // If the server supports the SORT extension, do the sorting server side + switch { + case imapw.caps.Sort && hasSortCriteria: + uids, err = imapw.client.sort.UidSort(sortCriteria, searchCriteria) + if err != nil { + imapw.worker.PostMessage(&types.Error{ + Message: types.RespondTo(msg), + Error: err, + }, nil) + return + } + // copy in reverse as msgList displays backwards + for i, j := 0, len(uids)-1; i < j; i, j = i+1, j-1 { + uids[i], uids[j] = uids[j], uids[i] + } + default: + if hasSortCriteria { + imapw.worker.Warnf("SORT is not supported but requested: list messages by UID") + } + uids, err = imapw.client.UidSearch(searchCriteria) + if err != nil { + imapw.worker.PostMessage(&types.Error{ + Message: types.RespondTo(msg), + Error: err, + }, nil) + return + } + } + if msg.Context.Err() != nil { + imapw.worker.PostMessage(&types.Cancelled{ + Message: types.RespondTo(msg), + }, nil) + return + } + imapw.worker.Tracef("Found %d UIDs", len(uids)) + if msg.Filter == nil { + // Only initialize if we are not filtering + imapw.seqMap.Initialize(uids) + } + + imapw.worker.PostMessage(&types.DirectoryContents{ + Message: types.RespondTo(msg), + Uids: models.Uint32ToUidList(uids), + }, nil) + imapw.worker.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil) +} + +type sortFieldMapT map[types.SortField]sortthread.SortField + +// caution, incomplete mapping +var sortFieldMap sortFieldMapT = sortFieldMapT{ + types.SortArrival: sortthread.SortArrival, + types.SortCc: sortthread.SortCc, + types.SortDate: sortthread.SortDate, + types.SortFrom: sortthread.SortFrom, + types.SortSize: sortthread.SortSize, + types.SortSubject: sortthread.SortSubject, + types.SortTo: sortthread.SortTo, +} + +func translateSortCriterions( + cs []*types.SortCriterion, +) []sortthread.SortCriterion { + result := make([]sortthread.SortCriterion, 0, len(cs)) + for _, c := range cs { + if f, ok := sortFieldMap[c.Field]; ok { + result = append(result, sortthread.SortCriterion{Field: f, Reverse: c.Reverse}) + } + } + return result +} + +func (imapw *IMAPWorker) handleDirectoryThreaded( + msg *types.FetchDirectoryThreaded, +) { + if msg.Context.Err() != nil { + imapw.worker.PostMessage(&types.Cancelled{ + Message: types.RespondTo(msg), + }, nil) + return + } + imapw.worker.Tracef("Fetching threaded UID list") + + searchCriteria := translateSearch(msg.Filter) + threads, err := imapw.client.thread.UidThread(imapw.threadAlgorithm, + searchCriteria) + if err != nil { + imapw.worker.PostMessage(&types.Error{ + Message: types.RespondTo(msg), + Error: err, + }, nil) + return + } + aercThreads, count := convertThreads(threads, nil) + sort.Sort(types.ByUID(aercThreads)) + imapw.worker.Tracef("Found %d threaded messages", count) + if msg.Filter == nil { + // Only initialize if we are not filtering + var uids []uint32 + for i := len(aercThreads) - 1; i >= 0; i-- { + aercThreads[i].Walk(func(t *types.Thread, level int, currentErr error) error { //nolint:errcheck // error indicates skipped threads + uids = append(uids, models.UidToUint32(t.Uid)) + return nil + }) + } + imapw.seqMap.Initialize(uids) + } + if msg.Context.Err() != nil { + imapw.worker.PostMessage(&types.Cancelled{ + Message: types.RespondTo(msg), + }, nil) + return + } + imapw.worker.PostMessage(&types.DirectoryThreaded{ + Message: types.RespondTo(msg), + Threads: aercThreads, + }, nil) + imapw.worker.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil) +} + +func convertThreads(threads []*sortthread.Thread, parent *types.Thread) ([]*types.Thread, int) { + if threads == nil { + return nil, 0 + } + conv := make([]*types.Thread, len(threads)) + count := 0 + + for i := 0; i < len(threads); i++ { + t := threads[i] + conv[i] = &types.Thread{ + Uid: models.Uint32ToUid(t.Id), + } + + // Set the first child node + children, childCount := convertThreads(t.Children, conv[i]) + if len(children) > 0 { + conv[i].FirstChild = children[0] + } + + // Set the parent node + if parent != nil { + conv[i].Parent = parent + + // elements of threads are siblings + if i > 0 { + conv[i].PrevSibling = conv[i-1] + conv[i-1].NextSibling = conv[i] + } + } + + count += childCount + 1 + } + return conv, count +} diff --git a/worker/imap/remove.go b/worker/imap/remove.go new file mode 100644 index 0000000..688b6a9 --- /dev/null +++ b/worker/imap/remove.go @@ -0,0 +1,19 @@ +package imap + +import ( + "git.sr.ht/~rjarry/aerc/worker/types" +) + +func (imapw *IMAPWorker) handleRemoveDirectory(msg *types.RemoveDirectory) { + if err := imapw.client.Delete(msg.Directory); err != nil { + if msg.Quiet { + return + } + imapw.worker.PostMessage(&types.Error{ + Message: types.RespondTo(msg), + Error: err, + }, nil) + } else { + imapw.worker.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil) + } +} diff --git a/worker/imap/search.go b/worker/imap/search.go new file mode 100644 index 0000000..4bcff69 --- /dev/null +++ b/worker/imap/search.go @@ -0,0 +1,52 @@ +package imap + +import ( + "strings" + + "github.com/emersion/go-imap" + + "git.sr.ht/~rjarry/aerc/worker/types" + "git.sr.ht/~rjarry/go-opt/v2" +) + +func translateSearch(c *types.SearchCriteria) *imap.SearchCriteria { + criteria := imap.NewSearchCriteria() + if c == nil { + return criteria + } + criteria.WithFlags = translateFlags(c.WithFlags) + criteria.WithoutFlags = translateFlags(c.WithoutFlags) + + if !c.StartDate.IsZero() { + criteria.SentSince = c.StartDate + } + if !c.StartDate.IsZero() { + criteria.SentBefore = c.EndDate + } + for k, v := range c.Headers { + criteria.Header[k] = v + } + for _, f := range c.From { + criteria.Header.Add("From", f) + } + for _, t := range c.To { + criteria.Header.Add("To", t) + } + for _, c := range c.Cc { + criteria.Header.Add("Cc", c) + } + terms := opt.LexArgs(strings.Join(c.Terms, " ")) + if terms.Count() > 0 { + switch { + case c.SearchAll: + criteria.Text = terms.Args() + case c.SearchBody: + criteria.Body = terms.Args() + default: + for _, term := range terms.Args() { + criteria.Header.Add("Subject", term) + } + } + } + return criteria +} diff --git a/worker/imap/seqmap.go b/worker/imap/seqmap.go new file mode 100644 index 0000000..1e64d37 --- /dev/null +++ b/worker/imap/seqmap.go @@ -0,0 +1,77 @@ +package imap + +import ( + "sort" + "sync" +) + +type SeqMap struct { + lock sync.Mutex + // map of IMAP sequence numbers to message UIDs + m []uint32 +} + +// Initialize sets the initial seqmap of the mailbox +func (s *SeqMap) Initialize(uids []uint32) { + s.lock.Lock() + s.m = make([]uint32, len(uids)) + copy(s.m, uids) + s.sort() + s.lock.Unlock() +} + +func (s *SeqMap) Size() int { + s.lock.Lock() + size := len(s.m) + s.lock.Unlock() + return size +} + +// Get returns the UID of the given seqnum +func (s *SeqMap) Get(seqnum uint32) (uint32, bool) { + if int(seqnum) > s.Size() || seqnum < 1 { + return 0, false + } + s.lock.Lock() + uid := s.m[seqnum-1] + s.lock.Unlock() + return uid, true +} + +// Put adds a UID to the slice. Put should only be used to add new messages +// into the slice +func (s *SeqMap) Put(uid uint32) { + s.lock.Lock() + for _, n := range s.m { + if n == uid { + // We already have this UID, don't insert it. + s.lock.Unlock() + return + } + } + s.m = append(s.m, uid) + s.sort() + s.lock.Unlock() +} + +// Pop removes seqnum from the SeqMap. seqnum must be a valid seqnum, ie +// [1:size of mailbox] +func (s *SeqMap) Pop(seqnum uint32) (uint32, bool) { + s.lock.Lock() + defer s.lock.Unlock() + if int(seqnum) > len(s.m) || seqnum < 1 { + return 0, false + } + uid := s.m[seqnum-1] + s.m = append(s.m[:seqnum-1], s.m[seqnum:]...) + return uid, true +} + +// sort sorts the slice in ascending UID order. See: +// https://datatracker.ietf.org/doc/html/rfc3501#section-2.3.1.2 +func (s *SeqMap) sort() { + // Always be sure the SeqMap is sorted + sort.Slice(s.m, func(i, j int) bool { + return s.m[i] < s.m[j] + }) +} diff --git a/worker/imap/seqmap_test.go b/worker/imap/seqmap_test.go new file mode 100644 index 0000000..5d6cf79 --- /dev/null +++ b/worker/imap/seqmap_test.go @@ -0,0 +1,85 @@ +package imap + +import ( + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestSeqMap(t *testing.T) { + var seqmap SeqMap + var uid uint32 + var found bool + assert := assert.New(t) + + assert.Equal(0, seqmap.Size()) + + _, found = seqmap.Get(42) + assert.Equal(false, found) + + _, found = seqmap.Pop(0) + assert.Equal(false, found) + + uids := []uint32{1337, 42, 1107} + seqmap.Initialize(uids) + assert.Equal(3, seqmap.Size()) + // Original list should remain unsorted + assert.Equal([]uint32{1337, 42, 1107}, uids) + + _, found = seqmap.Pop(0) + assert.Equal(false, found) + + uid, found = seqmap.Get(1) + assert.Equal(42, int(uid)) + assert.Equal(true, found) + + uid, found = seqmap.Pop(1) + assert.Equal(42, int(uid)) + assert.Equal(true, found) + assert.Equal(2, seqmap.Size()) + + uid, found = seqmap.Get(1) + assert.Equal(1107, int(uid)) + + // Repeated puts of the same UID shouldn't change the size + seqmap.Put(1231) + assert.Equal(3, seqmap.Size()) + seqmap.Put(1231) + assert.Equal(3, seqmap.Size()) + + uid, found = seqmap.Get(2) + assert.Equal(1231, int(uid)) + + _, found = seqmap.Pop(1) + assert.Equal(true, found) + assert.Equal(2, seqmap.Size()) + + seqmap.Initialize(nil) + assert.Equal(0, seqmap.Size()) + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + seqmap.Initialize([]uint32{42, 1337}) + }() + wg.Add(1) + go func() { + defer wg.Done() + for _, found := seqmap.Pop(1); !found; _, found = seqmap.Pop(1) { + time.Sleep(1 * time.Millisecond) + } + }() + wg.Add(1) + go func() { + defer wg.Done() + for _, found := seqmap.Pop(1); !found; _, found = seqmap.Pop(1) { + time.Sleep(1 * time.Millisecond) + } + }() + wg.Wait() + + assert.Equal(0, seqmap.Size()) +} diff --git a/worker/imap/worker.go b/worker/imap/worker.go new file mode 100644 index 0000000..28eb907 --- /dev/null +++ b/worker/imap/worker.go @@ -0,0 +1,397 @@ +package imap + +import ( + "fmt" + "net/url" + "time" + + "github.com/emersion/go-imap" + sortthread "github.com/emersion/go-imap-sortthread" + "github.com/emersion/go-imap/client" + "github.com/pkg/errors" + "github.com/syndtr/goleveldb/leveldb" + + "git.sr.ht/~rjarry/aerc/lib" + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/handlers" + "git.sr.ht/~rjarry/aerc/worker/imap/extensions" + "git.sr.ht/~rjarry/aerc/worker/middleware" + "git.sr.ht/~rjarry/aerc/worker/types" +) + +func init() { + handlers.RegisterWorkerFactory("imap", NewIMAPWorker) + handlers.RegisterWorkerFactory("imaps", NewIMAPWorker) +} + +var ( + errUnsupported = fmt.Errorf("unsupported command") + errClientNotReady = fmt.Errorf("client not ready") + errNotConnected = fmt.Errorf("not connected") + errAlreadyConnected = fmt.Errorf("already connected") +) + +type imapClient struct { + *client.Client + thread *sortthread.ThreadClient + sort *sortthread.SortClient + liststatus *extensions.ListStatusClient +} + +type imapConfig struct { + name string + scheme string + insecure bool + addr string + user *url.Userinfo + headers []string + headersExclude []string + folders []string + oauthBearer lib.OAuthBearer + xoauth2 lib.Xoauth2 + idle_timeout time.Duration + idle_debounce time.Duration + reconnect_maxwait time.Duration + // tcp connection parameters + connection_timeout time.Duration + keepalive_period time.Duration + keepalive_probes int + keepalive_interval int + cacheEnabled bool + cacheMaxAge time.Duration + useXGMEXT bool +} + +type IMAPWorker struct { + config imapConfig + + client *imapClient + selected *imap.MailboxStatus + updates chan client.Update + worker types.WorkerInteractor + seqMap SeqMap + delimiter string + + idler *idler + observer *observer + cache *leveldb.DB + + caps *models.Capabilities + + threadAlgorithm sortthread.ThreadAlgorithm + liststatus bool + + executeIdle chan struct{} +} + +func NewIMAPWorker(worker *types.Worker) (types.Backend, error) { + return &IMAPWorker{ + updates: make(chan client.Update, 50), + worker: worker, + selected: &imap.MailboxStatus{}, + idler: nil, // will be set in configure() + observer: nil, // will be set in configure() + caps: &models.Capabilities{}, + executeIdle: make(chan struct{}), + }, nil +} + +func (w *IMAPWorker) newClient(c *client.Client) { + c.Updates = nil + w.client = &imapClient{ + c, + sortthread.NewThreadClient(c), + sortthread.NewSortClient(c), + extensions.NewListStatusClient(c), + } + if w.idler != nil { + w.idler.SetClient(w.client) + c.Updates = w.updates + } + if w.observer != nil { + w.observer.SetClient(w.client) + } + sort, err := w.client.sort.SupportSort() + if err == nil && sort { + w.caps.Sort = true + w.worker.Debugf("Server Capability found: Sort") + } + for _, alg := range []sortthread.ThreadAlgorithm{sortthread.References, sortthread.OrderedSubject} { + ok, err := w.client.Support(fmt.Sprintf("THREAD=%s", string(alg))) + if err == nil && ok { + w.threadAlgorithm = alg + w.caps.Thread = true + w.worker.Debugf("Server Capability found: Thread (algorithm: %s)", string(alg)) + break + } + } + lStatus, err := w.client.liststatus.SupportListStatus() + if err == nil && lStatus { + w.liststatus = true + w.caps.Extensions = append(w.caps.Extensions, "LIST-STATUS") + w.worker.Debugf("Server Capability found: LIST-STATUS") + } + xgmext, err := w.client.Support("X-GM-EXT-1") + if err == nil && xgmext && w.config.useXGMEXT { + w.caps.Extensions = append(w.caps.Extensions, "X-GM-EXT-1") + w.worker.Debugf("Server Capability found: X-GM-EXT-1") + w.worker = middleware.NewGmailWorker(w.worker, w.client.Client) + } + if err == nil && !xgmext && w.config.useXGMEXT { + w.worker.Infof("X-GM-EXT-1 requested, but it is not supported") + } +} + +func (w *IMAPWorker) handleMessage(msg types.WorkerMessage) error { + var reterr error // will be returned at the end, needed to support idle + + // when client is nil allow only certain messages to be handled + if w.client == nil { + switch msg.(type) { + case *types.Connect, *types.Reconnect, *types.Disconnect, *types.Configure: + default: + return errClientNotReady + } + } + + // set connection timeout for calls to imap server + if w.client != nil { + w.client.Timeout = w.config.connection_timeout + } + + switch msg := msg.(type) { + case *types.Unsupported: + // No-op + case *types.Configure: + reterr = w.handleConfigure(msg) + case *types.Connect: + if w.client != nil && w.client.State() == imap.SelectedState { + if !w.observer.AutoReconnect() { + w.observer.SetAutoReconnect(true) + w.observer.EmitIfNotConnected() + } + reterr = errAlreadyConnected + break + } + + w.observer.SetAutoReconnect(true) + c, err := w.connect() + if err != nil { + w.observer.EmitIfNotConnected() + reterr = err + break + } + + w.newClient(c) + + w.worker.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil) + case *types.Reconnect: + if !w.observer.AutoReconnect() { + reterr = fmt.Errorf("auto-reconnect is disabled; run connect to enable it") + break + } + c, err := w.connect() + if err != nil { + errReconnect := w.observer.DelayedReconnect() + reterr = errors.Wrap(errReconnect, err.Error()) + break + } + + w.newClient(c) + + w.worker.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil) + case *types.Disconnect: + w.observer.SetAutoReconnect(false) + w.observer.Stop() + + if w.client == nil || (w.client != nil && w.client.State() != imap.SelectedState) { + reterr = errNotConnected + break + } + + if err := w.client.Logout(); err != nil { + w.terminate() + reterr = err + break + } + w.worker.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil) + case *types.ListDirectories: + w.handleListDirectories(msg) + case *types.OpenDirectory: + w.handleOpenDirectory(msg) + case *types.FetchDirectoryContents: + w.handleFetchDirectoryContents(msg) + case *types.FetchDirectoryThreaded: + w.handleDirectoryThreaded(msg) + case *types.CreateDirectory: + w.handleCreateDirectory(msg) + case *types.RemoveDirectory: + w.handleRemoveDirectory(msg) + case *types.FetchMessageHeaders: + w.handleFetchMessageHeaders(msg) + case *types.FetchMessageBodyPart: + w.handleFetchMessageBodyPart(msg) + case *types.FetchFullMessages: + w.handleFetchFullMessages(msg) + case *types.FetchMessageFlags: + w.handleFetchMessageFlags(msg) + case *types.DeleteMessages: + w.handleDeleteMessages(msg) + case *types.FlagMessages: + w.handleFlagMessages(msg) + case *types.AnsweredMessages: + w.handleAnsweredMessages(msg) + case *types.CopyMessages: + w.handleCopyMessages(msg) + case *types.MoveMessages: + w.handleMoveMessages(msg) + case *types.AppendMessage: + w.handleAppendMessage(msg) + case *types.SearchDirectory: + w.handleSearchDirectory(msg) + case *types.CheckMail: + w.handleCheckMailMessage(msg) + default: + reterr = errUnsupported + } + + // we don't want idle to timeout, so set timeout to zero + if w.client != nil { + w.client.Timeout = 0 + } + + return reterr +} + +func (w *IMAPWorker) handleImapUpdate(update client.Update) { + w.worker.Tracef("(= %T", update) + switch update := update.(type) { + case *client.MailboxUpdate: + w.worker.PostAction(&types.CheckMail{ + Directories: []string{update.Mailbox.Name}, + }, nil) + case *client.MessageUpdate: + msg := update.Message + if msg.Uid == 0 { + if uid, found := w.seqMap.Get(msg.SeqNum); !found { + w.worker.Errorf("MessageUpdate unknown seqnum: %d", msg.SeqNum) + return + } else { + msg.Uid = uid + } + } + if int(msg.SeqNum) > w.seqMap.Size() { + w.seqMap.Put(msg.Uid) + } + w.worker.PostMessage(&types.MessageInfo{ + Info: &models.MessageInfo{ + BodyStructure: translateBodyStructure(msg.BodyStructure), + Envelope: translateEnvelope(msg.Envelope), + Flags: translateImapFlags(msg.Flags), + InternalDate: msg.InternalDate, + Uid: models.Uint32ToUid(msg.Uid), + }, + }, nil) + case *client.ExpungeUpdate: + if uid, found := w.seqMap.Pop(update.SeqNum); !found { + w.worker.Errorf("ExpungeUpdate unknown seqnum: %d", update.SeqNum) + } else { + w.worker.PostMessage(&types.MessagesDeleted{ + Uids: []models.UID{models.Uint32ToUid(uid)}, + }, nil) + } + } +} + +func (w *IMAPWorker) terminate() { + if w.observer != nil { + w.observer.Stop() + w.observer.SetClient(nil) + } + + if w.client != nil { + w.client.Updates = nil + if err := w.client.Terminate(); err != nil { + w.worker.Errorf("could not terminate connection: %v", err) + } + } + + w.client = nil + w.selected = &imap.MailboxStatus{} + + if w.idler != nil { + w.idler.SetClient(nil) + } +} + +func (w *IMAPWorker) stopIdler() error { + if w.idler == nil { + return nil + } + + if err := w.idler.Stop(); err != nil { + w.terminate() + w.observer.EmitIfNotConnected() + w.worker.Errorf("idler stopped with error:%v", err) + return err + } + + return nil +} + +func (w *IMAPWorker) startIdler() { + if w.idler == nil { + return + } + + w.idler.Start() +} + +func (w *IMAPWorker) Run() { + for { + select { + case msg := <-w.worker.Actions(): + + if err := w.stopIdler(); err != nil { + w.worker.PostMessage(&types.Error{ + Message: types.RespondTo(msg), + Error: err, + }, nil) + break + } + w.worker.Tracef("ready to handle %T", msg) + + msg = w.worker.ProcessAction(msg) + + if err := w.handleMessage(msg); errors.Is(err, errUnsupported) { + w.worker.PostMessage(&types.Unsupported{ + Message: types.RespondTo(msg), + }, nil) + } else if err != nil { + w.worker.PostMessage(&types.Error{ + Message: types.RespondTo(msg), + Error: err, + }, nil) + } + + w.startIdler() + + case update := <-w.updates: + w.handleImapUpdate(update) + + case <-w.executeIdle: + w.idler.Execute() + } + } +} + +func (w *IMAPWorker) Capabilities() *models.Capabilities { + return w.caps +} + +func (w *IMAPWorker) PathSeparator() string { + if w.delimiter == "" { + return "/" + } + return w.delimiter +} diff --git a/worker/jmap/cache/blob.go b/worker/jmap/cache/blob.go new file mode 100644 index 0000000..e704f2c --- /dev/null +++ b/worker/jmap/cache/blob.go @@ -0,0 +1,45 @@ +package cache + +import ( + "os" + "path" + + "git.sr.ht/~rockorager/go-jmap" +) + +func (c *JMAPCache) GetBlob(id jmap.ID) ([]byte, error) { + fpath := c.blobPath(id) + if fpath == "" { + return nil, notfound + } + return os.ReadFile(fpath) +} + +func (c *JMAPCache) PutBlob(id jmap.ID, buf []byte) error { + fpath := c.blobPath(id) + if fpath == "" { + return nil + } + _ = os.MkdirAll(path.Dir(fpath), 0o700) + return os.WriteFile(fpath, buf, 0o600) +} + +func (c *JMAPCache) DeleteBlob(id jmap.ID) error { + fpath := c.blobPath(id) + if fpath == "" { + return nil + } + defer func() { + _ = os.Remove(path.Dir(fpath)) + }() + return os.Remove(fpath) +} + +func (c *JMAPCache) blobPath(id jmap.ID) string { + if c.blobsDir == "" || id == "" { + return "" + } + name := string(id) + sub := name[len(name)-2:] + return path.Join(c.blobsDir, sub, name) +} diff --git a/worker/jmap/cache/cache.go b/worker/jmap/cache/cache.go new file mode 100644 index 0000000..429dafe --- /dev/null +++ b/worker/jmap/cache/cache.go @@ -0,0 +1,109 @@ +package cache + +import ( + "errors" + "os" + "path" + "strings" + + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/xdg" + "github.com/syndtr/goleveldb/leveldb" + "github.com/syndtr/goleveldb/leveldb/util" +) + +type JMAPCache struct { + mem map[string][]byte + file *leveldb.DB + blobsDir string +} + +func NewJMAPCache(state, blobs bool, accountName string) *JMAPCache { + c := new(JMAPCache) + cacheDir := xdg.CachePath() + if state && cacheDir != "" { + var err error + dir := path.Join(cacheDir, "aerc", accountName, "state") + _ = os.MkdirAll(dir, 0o700) + c.file, err = leveldb.OpenFile(dir, nil) + if err != nil { + log.Errorf("failed to open goleveldb: %s", err) + c.mem = make(map[string][]byte) + } + } else { + c.mem = make(map[string][]byte) + } + if blobs && cacheDir != "" { + c.blobsDir = path.Join(cacheDir, "aerc", accountName, "blobs") + } + return c +} + +var notfound = errors.New("key not found") + +func (c *JMAPCache) get(key string) ([]byte, error) { + switch { + case c.file != nil: + return c.file.Get([]byte(key), nil) + case c.mem != nil: + value, ok := c.mem[key] + if !ok { + return nil, notfound + } + return value, nil + } + panic("jmap cache with no backend") +} + +func (c *JMAPCache) put(key string, value []byte) error { + switch { + case c.file != nil: + return c.file.Put([]byte(key), value, nil) + case c.mem != nil: + c.mem[key] = value + return nil + } + panic("jmap cache with no backend") +} + +func (c *JMAPCache) delete(key string) error { + switch { + case c.file != nil: + return c.file.Delete([]byte(key), nil) + case c.mem != nil: + delete(c.mem, key) + return nil + } + panic("jmap cache with no backend") +} + +func (c *JMAPCache) purge(prefix string) error { + switch { + case c.file != nil: + txn, err := c.file.OpenTransaction() + if err != nil { + return err + } + iter := txn.NewIterator(util.BytesPrefix([]byte(prefix)), nil) + for iter.Next() { + err = txn.Delete(iter.Key(), nil) + if err != nil { + break + } + } + iter.Release() + if err != nil { + txn.Discard() + return err + } + return txn.Commit() + case c.mem != nil: + for key := range c.mem { + if strings.HasPrefix(key, prefix) { + delete(c.mem, key) + } + } + return nil + } + panic("jmap cache with no backend") +} diff --git a/worker/jmap/cache/email.go b/worker/jmap/cache/email.go new file mode 100644 index 0000000..529df6d --- /dev/null +++ b/worker/jmap/cache/email.go @@ -0,0 +1,40 @@ +package cache + +import ( + "git.sr.ht/~rockorager/go-jmap" + "git.sr.ht/~rockorager/go-jmap/mail/email" +) + +func (c *JMAPCache) HasEmail(id jmap.ID) bool { + _, err := c.get(emailKey(id)) + return err == nil +} + +func (c *JMAPCache) GetEmail(id jmap.ID) (*email.Email, error) { + buf, err := c.get(emailKey(id)) + if err != nil { + return nil, err + } + e := new(email.Email) + err = unmarshal(buf, e) + if err != nil { + return nil, err + } + return e, nil +} + +func (c *JMAPCache) PutEmail(id jmap.ID, e *email.Email) error { + buf, err := marshal(e) + if err != nil { + return err + } + return c.put(emailKey(id), buf) +} + +func (c *JMAPCache) DeleteEmail(id jmap.ID) error { + return c.delete(emailKey(id)) +} + +func emailKey(id jmap.ID) string { + return "email/" + string(id) +} diff --git a/worker/jmap/cache/folder_contents.go b/worker/jmap/cache/folder_contents.go new file mode 100644 index 0000000..3c40e80 --- /dev/null +++ b/worker/jmap/cache/folder_contents.go @@ -0,0 +1,59 @@ +package cache + +import ( + "reflect" + + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/worker/types" + "git.sr.ht/~rockorager/go-jmap" +) + +type FolderContents struct { + MailboxID jmap.ID + QueryState string + Filter *types.SearchCriteria + Sort []*types.SortCriterion + MessageIDs []jmap.ID +} + +func (c *JMAPCache) GetFolderContents(mailboxId jmap.ID) (*FolderContents, error) { + key := folderContentsKey(mailboxId) + buf, err := c.get(key) + if err != nil { + return nil, err + } + m := new(FolderContents) + err = unmarshal(buf, m) + if err != nil { + log.Debugf("cache format has changed, purging foldercontents") + if e := c.purge("foldercontents/"); e != nil { + log.Errorf("foldercontents cache purge: %s", e) + } + return nil, err + } + return m, nil +} + +func (c *JMAPCache) PutFolderContents(mailboxId jmap.ID, m *FolderContents) error { + buf, err := marshal(m) + if err != nil { + return err + } + return c.put(folderContentsKey(mailboxId), buf) +} + +func (c *JMAPCache) DeleteFolderContents(mailboxId jmap.ID) error { + return c.delete(folderContentsKey(mailboxId)) +} + +func folderContentsKey(mailboxId jmap.ID) string { + return "foldercontents/" + string(mailboxId) +} + +func (f *FolderContents) NeedsRefresh( + filter *types.SearchCriteria, sort []*types.SortCriterion, +) bool { + return f.QueryState == "" || + !reflect.DeepEqual(f.Sort, sort) || + !reflect.DeepEqual(f.Filter, filter) +} diff --git a/worker/jmap/cache/gob.go b/worker/jmap/cache/gob.go new file mode 100644 index 0000000..f1b8be3 --- /dev/null +++ b/worker/jmap/cache/gob.go @@ -0,0 +1,33 @@ +package cache + +import ( + "bytes" + "encoding/gob" + + "git.sr.ht/~rockorager/go-jmap/mail/email" + "git.sr.ht/~rockorager/go-jmap/mail/mailbox" +) + +type jmapObject interface { + *email.Email | + *email.QueryResponse | + *mailbox.Mailbox | + *FolderContents | + *IDList +} + +func marshal[T jmapObject](obj T) ([]byte, error) { + buf := bytes.NewBuffer(nil) + encoder := gob.NewEncoder(buf) + err := encoder.Encode(obj) + if err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func unmarshal[T jmapObject](data []byte, obj T) error { + buf := bytes.NewBuffer(data) + decoder := gob.NewDecoder(buf) + return decoder.Decode(obj) +} diff --git a/worker/jmap/cache/mailbox.go b/worker/jmap/cache/mailbox.go new file mode 100644 index 0000000..4438877 --- /dev/null +++ b/worker/jmap/cache/mailbox.go @@ -0,0 +1,35 @@ +package cache + +import ( + "git.sr.ht/~rockorager/go-jmap" + "git.sr.ht/~rockorager/go-jmap/mail/mailbox" +) + +func (c *JMAPCache) GetMailbox(id jmap.ID) (*mailbox.Mailbox, error) { + buf, err := c.get(mailboxKey(id)) + if err != nil { + return nil, err + } + m := new(mailbox.Mailbox) + err = unmarshal(buf, m) + if err != nil { + return nil, err + } + return m, nil +} + +func (c *JMAPCache) PutMailbox(id jmap.ID, m *mailbox.Mailbox) error { + buf, err := marshal(m) + if err != nil { + return err + } + return c.put(mailboxKey(id), buf) +} + +func (c *JMAPCache) DeleteMailbox(id jmap.ID) error { + return c.delete(mailboxKey(id)) +} + +func mailboxKey(id jmap.ID) string { + return "mailbox/" + string(id) +} diff --git a/worker/jmap/cache/mailbox_list.go b/worker/jmap/cache/mailbox_list.go new file mode 100644 index 0000000..fb9bd3e --- /dev/null +++ b/worker/jmap/cache/mailbox_list.go @@ -0,0 +1,32 @@ +package cache + +import ( + "git.sr.ht/~rockorager/go-jmap" +) + +type IDList struct { + IDs []jmap.ID +} + +func (c *JMAPCache) GetMailboxList() ([]jmap.ID, error) { + buf, err := c.get(mailboxListKey) + if err != nil { + return nil, err + } + var list IDList + err = unmarshal(buf, &list) + if err != nil { + return nil, err + } + return list.IDs, nil +} + +func (c *JMAPCache) PutMailboxList(list []jmap.ID) error { + buf, err := marshal(&IDList{IDs: list}) + if err != nil { + return err + } + return c.put(mailboxListKey, buf) +} + +const mailboxListKey = "mailbox/list" diff --git a/worker/jmap/cache/session.go b/worker/jmap/cache/session.go new file mode 100644 index 0000000..3769979 --- /dev/null +++ b/worker/jmap/cache/session.go @@ -0,0 +1,34 @@ +package cache + +import ( + "encoding/json" + + "git.sr.ht/~rockorager/go-jmap" +) + +func (c *JMAPCache) GetSession() (*jmap.Session, error) { + buf, err := c.get(sessionKey) + if err != nil { + return nil, err + } + s := new(jmap.Session) + err = json.Unmarshal(buf, s) + if err != nil { + return nil, err + } + return s, nil +} + +func (c *JMAPCache) PutSession(s *jmap.Session) error { + buf, err := json.Marshal(s) + if err != nil { + return err + } + return c.put(sessionKey, buf) +} + +func (c *JMAPCache) DeleteSession() error { + return c.delete(sessionKey) +} + +const sessionKey = "session" diff --git a/worker/jmap/cache/state.go b/worker/jmap/cache/state.go new file mode 100644 index 0000000..e777107 --- /dev/null +++ b/worker/jmap/cache/state.go @@ -0,0 +1,43 @@ +package cache + +func (c *JMAPCache) GetMailboxState() (string, error) { + buf, err := c.get(mailboxStateKey) + if err != nil { + return "", err + } + return string(buf), nil +} + +func (c *JMAPCache) PutMailboxState(state string) error { + return c.put(mailboxStateKey, []byte(state)) +} + +func (c *JMAPCache) GetEmailState() (string, error) { + buf, err := c.get(emailStateKey) + if err != nil { + return "", err + } + return string(buf), nil +} + +func (c *JMAPCache) PutEmailState(state string) error { + return c.put(emailStateKey, []byte(state)) +} + +func (c *JMAPCache) GetThreadState() (string, error) { + buf, err := c.get(threadStateKey) + if err != nil { + return "", err + } + return string(buf), nil +} + +func (c *JMAPCache) PutThreadState(state string) error { + return c.put(threadStateKey, []byte(state)) +} + +const ( + mailboxStateKey = "state/mailbox" + emailStateKey = "state/email" + threadStateKey = "state/thread" +) diff --git a/worker/jmap/cache/thread.go b/worker/jmap/cache/thread.go new file mode 100644 index 0000000..fedbd30 --- /dev/null +++ b/worker/jmap/cache/thread.go @@ -0,0 +1,34 @@ +package cache + +import ( + "git.sr.ht/~rockorager/go-jmap" +) + +func (c *JMAPCache) GetThread(id jmap.ID) ([]jmap.ID, error) { + buf, err := c.get(threadKey(id)) + if err != nil { + return nil, err + } + var list IDList + err = unmarshal(buf, &list) + if err != nil { + return nil, err + } + return list.IDs, nil +} + +func (c *JMAPCache) PutThread(id jmap.ID, list []jmap.ID) error { + buf, err := marshal(&IDList{IDs: list}) + if err != nil { + return err + } + return c.put(threadKey(id), buf) +} + +func (c *JMAPCache) DeleteThread(id jmap.ID) error { + return c.delete(mailboxKey(id)) +} + +func threadKey(id jmap.ID) string { + return "thread/" + string(id) +} diff --git a/worker/jmap/configure.go b/worker/jmap/configure.go new file mode 100644 index 0000000..dd6f3f3 --- /dev/null +++ b/worker/jmap/configure.go @@ -0,0 +1,66 @@ +package jmap + +import ( + "fmt" + "net/url" + "strings" + "time" + + "git.sr.ht/~rjarry/aerc/worker/jmap/cache" + "git.sr.ht/~rjarry/aerc/worker/types" +) + +func (w *JMAPWorker) handleConfigure(msg *types.Configure) error { + w.config.cacheState = parseBool(msg.Config.Params["cache-state"]) + w.config.cacheBlobs = parseBool(msg.Config.Params["cache-blobs"]) + w.config.useLabels = parseBool(msg.Config.Params["use-labels"]) + w.cache = cache.NewJMAPCache( + w.config.cacheState, w.config.cacheBlobs, msg.Config.Name) + + u, err := url.Parse(msg.Config.Source) + if err != nil { + return err + } + + if strings.HasSuffix(u.Scheme, "+oauthbearer") { + w.config.oauth = true + } else { + if u.User == nil { + return fmt.Errorf("user:password not specified") + } else if u.User.Username() == "" { + return fmt.Errorf("username not specified") + } else if _, ok := u.User.Password(); !ok { + return fmt.Errorf("password not specified") + } + } + + u.RawQuery = "" + u.Fragment = "" + w.config.user = u.User + u.User = nil + u.Scheme = "https" + + w.config.endpoint = u.String() + w.config.account = msg.Config + w.config.allMail = msg.Config.Params["all-mail"] + if w.config.allMail == "" { + w.config.allMail = "All mail" + } + if ping, ok := msg.Config.Params["server-ping"]; ok { + dur, err := time.ParseDuration(ping) + if err != nil { + return fmt.Errorf("server-ping: %w", err) + } + w.config.serverPing = dur + } + + return nil +} + +func parseBool(val string) bool { + switch strings.ToLower(val) { + case "1", "t", "true", "yes", "y", "on": + return true + } + return false +} diff --git a/worker/jmap/connect.go b/worker/jmap/connect.go new file mode 100644 index 0000000..2a4eebe --- /dev/null +++ b/worker/jmap/connect.go @@ -0,0 +1,146 @@ +package jmap + +import ( + "encoding/json" + "io" + "strings" + "sync/atomic" + + "git.sr.ht/~rjarry/aerc/worker/types" + "git.sr.ht/~rockorager/go-jmap" + "git.sr.ht/~rockorager/go-jmap/mail" + "git.sr.ht/~rockorager/go-jmap/mail/identity" +) + +func (w *JMAPWorker) handleConnect(msg *types.Connect) error { + w.client = &jmap.Client{SessionEndpoint: w.config.endpoint} + + if w.config.oauth { + pass, _ := w.config.user.Password() + w.client.WithAccessToken(pass) + } else { + user := w.config.user.Username() + pass, _ := w.config.user.Password() + w.client.WithBasicAuth(user, pass) + } + + if session, err := w.cache.GetSession(); err == nil { + w.client.Session = session + } + if w.client.Session == nil { + if err := w.UpdateSession(); err != nil { + return err + } + } + + go w.monitorChanges() + + return nil +} + +func (w *JMAPWorker) AccountId() jmap.ID { + switch { + case w.client == nil: + fallthrough + case w.client.Session == nil: + fallthrough + case w.client.Session.PrimaryAccounts == nil: + return "" + default: + return w.client.Session.PrimaryAccounts[mail.URI] + } +} + +func (w *JMAPWorker) UpdateSession() error { + if err := w.client.Authenticate(); err != nil { + return err + } + if err := w.cache.PutSession(w.client.Session); err != nil { + w.w.Warnf("PutSession: %s", err) + } + return nil +} + +func (w *JMAPWorker) GetIdentities() error { + var req jmap.Request + + req.Invoke(&identity.Get{Account: w.AccountId()}) + resp, err := w.Do(&req) + if err != nil { + return err + } + for _, inv := range resp.Responses { + switch r := inv.Args.(type) { + case *identity.GetResponse: + for _, ident := range r.List { + w.identities[ident.Email] = ident + } + case *jmap.MethodError: + return wrapMethodError(r) + } + } + + return nil +} + +var seqnum uint64 + +func (w *JMAPWorker) Do(req *jmap.Request) (*jmap.Response, error) { + seq := atomic.AddUint64(&seqnum, 1) + body, _ := json.Marshal(req.Calls) + w.w.Debugf(">%d> POST %s", seq, body) + resp, err := w.client.Do(req) + if err != nil { + w.w.Debugf("<%d< %s", seq, err) + // Try to update session in case an endpoint changed + err := w.UpdateSession() + if err != nil { + return nil, err + } + // And try again if we succeeded + resp, err = w.client.Do(req) + if err != nil { + return nil, err + } + } + if resp.SessionState != w.client.Session.State { + if err := w.UpdateSession(); err != nil { + return nil, err + } + } + w.w.Debugf("<%d< done", seq) + return resp, err +} + +func (w *JMAPWorker) Download(blobID jmap.ID) (io.ReadCloser, error) { + seq := atomic.AddUint64(&seqnum, 1) + replacer := strings.NewReplacer( + "{accountId}", string(w.AccountId()), + "{blobId}", string(blobID), + "{type}", "application/octet-stream", + "{name}", "filename", + ) + url := replacer.Replace(w.client.Session.DownloadURL) + w.w.Debugf(">%d> GET %s", seq, url) + rd, err := w.client.Download(w.AccountId(), blobID) + if err == nil { + w.w.Debugf("<%d< 200 OK", seq) + } else { + w.w.Debugf("<%d< %s", seq, err) + } + return rd, err +} + +func (w *JMAPWorker) Upload(reader io.Reader) (*jmap.UploadResponse, error) { + seq := atomic.AddUint64(&seqnum, 1) + url := strings.ReplaceAll(w.client.Session.UploadURL, + "{accountId}", string(w.AccountId())) + w.w.Debugf(">%d> POST %s", seq, url) + resp, err := w.client.Upload(w.AccountId(), reader) + if err == nil { + w.w.Debugf("<%d< 200 OK", seq) + } else { + w.w.Debugf("<%d< %s", seq, err) + } + return resp, err +} diff --git a/worker/jmap/directories.go b/worker/jmap/directories.go new file mode 100644 index 0000000..f96a59d --- /dev/null +++ b/worker/jmap/directories.go @@ -0,0 +1,345 @@ +package jmap + +import ( + "errors" + "fmt" + "path" + "sort" + + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/jmap/cache" + "git.sr.ht/~rjarry/aerc/worker/types" + "git.sr.ht/~rockorager/go-jmap" + "git.sr.ht/~rockorager/go-jmap/mail/email" + "git.sr.ht/~rockorager/go-jmap/mail/mailbox" +) + +func (w *JMAPWorker) handleListDirectories(msg *types.ListDirectories) error { + var ids, missing []jmap.ID + var labels []string + var mboxes map[jmap.ID]*mailbox.Mailbox + + mboxes = make(map[jmap.ID]*mailbox.Mailbox) + + // If we can't get the cached mailbox state, at worst, we will just + // query information we might already know + cachedMailboxState, err := w.cache.GetMailboxState() + if err != nil { + w.w.Warnf("GetMailboxState: %s", err) + } + + mboxIds, err := w.cache.GetMailboxList() + if err == nil { + for _, id := range mboxIds { + mbox, err := w.cache.GetMailbox(id) + if err != nil { + w.w.Warnf("GetMailbox: %s", err) + missing = append(missing, id) + continue + } + mboxes[id] = mbox + ids = append(ids, id) + } + } + + if cachedMailboxState == "" || len(missing) > 0 { + var req jmap.Request + + req.Invoke(&mailbox.Get{Account: w.AccountId()}) + resp, err := w.Do(&req) + if err != nil { + return err + } + + mboxes = make(map[jmap.ID]*mailbox.Mailbox) + ids = make([]jmap.ID, 0) + + for _, inv := range resp.Responses { + switch r := inv.Args.(type) { + case *mailbox.GetResponse: + for _, mbox := range r.List { + mboxes[mbox.ID] = mbox + ids = append(ids, mbox.ID) + err = w.cache.PutMailbox(mbox.ID, mbox) + if err != nil { + w.w.Warnf("PutMailbox: %s", err) + } + } + err = w.cache.PutMailboxList(ids) + if err != nil { + w.w.Warnf("PutMailboxList: %s", err) + } + err = w.cache.PutMailboxState(r.State) + if err != nil { + w.w.Warnf("PutMailboxState: %s", err) + } + case *jmap.MethodError: + return wrapMethodError(r) + } + } + } + + if len(mboxes) == 0 { + return errors.New("no mailboxes") + } + + for _, mbox := range mboxes { + dir := w.MailboxPath(mbox) + w.addMbox(mbox, dir) + labels = append(labels, dir) + } + if w.config.useLabels { + sort.Strings(labels) + w.w.PostMessage(&types.LabelList{Labels: labels}, nil) + } + + for _, id := range ids { + mbox := mboxes[id] + if mbox.Role == mailbox.RoleArchive && w.config.useLabels { + // replace archive with virtual all-mail folder + mbox = &mailbox.Mailbox{ + Name: w.config.allMail, + Role: mailbox.RoleAll, + } + w.addMbox(mbox, mbox.Name) + } + w.w.PostMessage(&types.Directory{ + Message: types.RespondTo(msg), + Dir: &models.Directory{ + Name: w.mbox2dir[mbox.ID], + Exists: int(mbox.TotalEmails), + Unseen: int(mbox.UnreadEmails), + Role: jmapRole2aerc[mbox.Role], + }, + }, nil) + } + + return nil +} + +func (w *JMAPWorker) handleOpenDirectory(msg *types.OpenDirectory) error { + id, ok := w.dir2mbox[msg.Directory] + if !ok { + return fmt.Errorf("unknown directory: %s", msg.Directory) + } + w.selectedMbox = id + return nil +} + +func (w *JMAPWorker) handleFetchDirectoryContents(msg *types.FetchDirectoryContents) error { + contents, err := w.cache.GetFolderContents(w.selectedMbox) + if err != nil { + contents = &cache.FolderContents{ + MailboxID: w.selectedMbox, + } + } + + if contents.NeedsRefresh(msg.Filter, msg.SortCriteria) { + var req jmap.Request + + req.Invoke(&email.Query{ + Account: w.AccountId(), + Filter: w.translateSearch(w.selectedMbox, msg.Filter), + Sort: translateSort(msg.SortCriteria), + }) + resp, err := w.Do(&req) + if err != nil { + return err + } + var canCalculateChanges bool + for _, inv := range resp.Responses { + switch r := inv.Args.(type) { + case *email.QueryResponse: + contents.Sort = msg.SortCriteria + contents.Filter = msg.Filter + contents.QueryState = r.QueryState + contents.MessageIDs = r.IDs + canCalculateChanges = r.CanCalculateChanges + case *jmap.MethodError: + return wrapMethodError(r) + } + } + if canCalculateChanges { + err = w.cache.PutFolderContents(w.selectedMbox, contents) + if err != nil { + w.w.Warnf("PutFolderContents: %s", err) + } + } else { + w.w.Debugf("%q: server cannot calculate changes, flushing cache", + w.mbox2dir[w.selectedMbox]) + err = w.cache.DeleteFolderContents(w.selectedMbox) + if err != nil { + w.w.Warnf("DeleteFolderContents: %s", err) + } + } + } + + uids := make([]models.UID, 0, len(contents.MessageIDs)) + for _, id := range contents.MessageIDs { + uids = append(uids, models.UID(id)) + } + w.w.PostMessage(&types.DirectoryContents{ + Message: types.RespondTo(msg), + Uids: uids, + }, nil) + + return nil +} + +func (w *JMAPWorker) handleSearchDirectory(msg *types.SearchDirectory) error { + var req jmap.Request + + req.Invoke(&email.Query{ + Account: w.AccountId(), + Filter: w.translateSearch(w.selectedMbox, msg.Criteria), + }) + + resp, err := w.Do(&req) + if err != nil { + return err + } + + for _, inv := range resp.Responses { + switch r := inv.Args.(type) { + case *email.QueryResponse: + var uids []models.UID + for _, id := range r.IDs { + uids = append(uids, models.UID(id)) + } + w.w.PostMessage(&types.SearchResults{ + Message: types.RespondTo(msg), + Uids: uids, + }, nil) + case *jmap.MethodError: + return wrapMethodError(r) + } + } + + return nil +} + +func (w *JMAPWorker) handleCreateDirectory(msg *types.CreateDirectory) error { + var req jmap.Request + var parentId, id jmap.ID + + if id, ok := w.dir2mbox[msg.Directory]; ok { + // directory already exists + mbox, err := w.cache.GetMailbox(id) + if err != nil { + return err + } + if mbox.Role == mailbox.RoleArchive && w.config.useLabels { + return errNoop + } + return nil + } + if parent := path.Dir(msg.Directory); parent != "" && parent != "." { + var ok bool + if parentId, ok = w.dir2mbox[parent]; !ok { + return fmt.Errorf( + "parent mailbox %q does not exist", parent) + } + } + name := path.Base(msg.Directory) + id = jmap.ID(msg.Directory) + + req.Invoke(&mailbox.Set{ + Account: w.AccountId(), + Create: map[jmap.ID]*mailbox.Mailbox{ + id: { + ParentID: parentId, + Name: name, + }, + }, + }) + + resp, err := w.Do(&req) + if err != nil { + return err + } + for _, inv := range resp.Responses { + switch r := inv.Args.(type) { + case *mailbox.SetResponse: + if err := r.NotCreated[id]; err != nil { + e := wrapSetError(err) + if msg.Quiet { + w.w.Warnf("mailbox creation failed: %s", e) + } else { + return e + } + } + case *jmap.MethodError: + return wrapMethodError(r) + } + } + + return nil +} + +func (w *JMAPWorker) handleRemoveDirectory(msg *types.RemoveDirectory) error { + var req jmap.Request + + id, ok := w.dir2mbox[msg.Directory] + if !ok { + return fmt.Errorf("unknown mailbox: %s", msg.Directory) + } + + req.Invoke(&mailbox.Set{ + Account: w.AccountId(), + Destroy: []jmap.ID{id}, + OnDestroyRemoveEmails: msg.Quiet, + }) + + resp, err := w.Do(&req) + if err != nil { + return err + } + for _, inv := range resp.Responses { + switch r := inv.Args.(type) { + case *mailbox.SetResponse: + if err := r.NotDestroyed[id]; err != nil { + return wrapSetError(err) + } + case *jmap.MethodError: + return wrapMethodError(r) + } + } + + return nil +} + +func translateSort(criteria []*types.SortCriterion) []*email.SortComparator { + sort := make([]*email.SortComparator, 0, len(criteria)) + if len(criteria) == 0 { + criteria = []*types.SortCriterion{ + {Field: types.SortArrival, Reverse: true}, + } + } + for _, s := range criteria { + var cmp email.SortComparator + switch s.Field { + case types.SortArrival: + cmp.Property = "receivedAt" + case types.SortCc: + cmp.Property = "cc" + case types.SortDate: + cmp.Property = "receivedAt" + case types.SortFrom: + cmp.Property = "from" + case types.SortRead: + cmp.Keyword = "$seen" + case types.SortSize: + cmp.Property = "size" + case types.SortSubject: + cmp.Property = "subject" + case types.SortTo: + cmp.Property = "to" + default: + continue + } + cmp.IsAscending = s.Reverse + sort = append(sort, &cmp) + } + + return sort +} diff --git a/worker/jmap/fetch.go b/worker/jmap/fetch.go new file mode 100644 index 0000000..2a6e12b --- /dev/null +++ b/worker/jmap/fetch.go @@ -0,0 +1,220 @@ +package jmap + +import ( + "bytes" + "fmt" + "io" + "strings" + + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/types" + "git.sr.ht/~rockorager/go-jmap" + "git.sr.ht/~rockorager/go-jmap/mail/email" + "github.com/emersion/go-message/charset" +) + +var headersProperties = []string{ + "id", + "blobId", + "threadId", + "mailboxIds", + "keywords", + "size", + "receivedAt", + "headers", + "messageId", + "inReplyTo", + "references", + "from", + "to", + "cc", + "bcc", + "replyTo", + "subject", + "bodyStructure", +} + +func (w *JMAPWorker) handleFetchMessageHeaders(msg *types.FetchMessageHeaders) error { + emailIdsToFetch := make([]jmap.ID, 0, len(msg.Uids)) + currentEmails := make([]*email.Email, 0, len(msg.Uids)) + for _, uid := range msg.Uids { + jid := jmap.ID(uid) + m, err := w.cache.GetEmail(jid) + if err != nil { + // Message wasn't in cache; fetch it + emailIdsToFetch = append(emailIdsToFetch, jid) + continue + } + currentEmails = append(currentEmails, m) + // Get the UI updated immediately + w.w.PostMessage(&types.MessageInfo{ + Message: types.RespondTo(msg), + Info: w.translateMsgInfo(m), + }, nil) + } + + if len(emailIdsToFetch) > 0 { + var req jmap.Request + + req.Invoke(&email.Get{ + Account: w.AccountId(), + IDs: emailIdsToFetch, + Properties: headersProperties, + }) + + resp, err := w.Do(&req) + if err != nil { + return err + } + + for _, inv := range resp.Responses { + switch r := inv.Args.(type) { + case *email.GetResponse: + if err = w.cache.PutEmailState(r.State); err != nil { + w.w.Warnf("PutEmailState: %s", err) + } + currentEmails = append(currentEmails, r.List...) + case *jmap.MethodError: + return wrapMethodError(r) + } + } + } + + var threadsToFetch []jmap.ID + for _, eml := range currentEmails { + thread, err := w.cache.GetThread(eml.ThreadID) + if err != nil { + threadsToFetch = append(threadsToFetch, eml.ThreadID) + continue + } + for _, id := range thread { + m, err := w.cache.GetEmail(id) + if err != nil { + // This should never happen. If we have the + // thread in cache, we will have fetched it + // already or updated it from the update loop + w.w.Warnf("Email ID %s from Thread %s not in cache", id, eml.ThreadID) + continue + } + currentEmails = append(currentEmails, m) + // Get the UI updated immediately + w.w.PostMessage(&types.MessageInfo{ + Message: types.RespondTo(msg), + Info: w.translateMsgInfo(m), + }, nil) + } + } + + threadEmails, err := w.fetchEntireThreads(threadsToFetch) + if err != nil { + return err + } + + for _, m := range threadEmails { + w.w.PostMessage(&types.MessageInfo{ + Message: types.RespondTo(msg), + Info: w.translateMsgInfo(m), + }, nil) + if err := w.cache.PutEmail(m.ID, m); err != nil { + w.w.Warnf("PutEmail: %s", err) + } + } + + return nil +} + +func (w *JMAPWorker) handleFetchMessageBodyPart(msg *types.FetchMessageBodyPart) error { + mail, err := w.cache.GetEmail(jmap.ID(msg.Uid)) + if err != nil { + return fmt.Errorf("bug: unknown message id %s: %w", msg.Uid, err) + } + + part := mail.BodyStructure + for i, index := range msg.Part { + index -= 1 // convert to zero based offset + if index < len(part.SubParts) { + part = part.SubParts[index] + } else { + return fmt.Errorf( + "bug: invalid part index[%d]: %v", i, msg.Part) + } + } + + buf, err := w.cache.GetBlob(part.BlobID) + if err != nil { + rd, err := w.Download(part.BlobID) + if err != nil { + return w.wrapDownloadError("part", part.BlobID, err) + } + buf, err = io.ReadAll(rd) + rd.Close() + if err != nil { + return err + } + if err = w.cache.PutBlob(part.BlobID, buf); err != nil { + w.w.Warnf("PutBlob: %s", err) + } + } + var reader io.Reader = bytes.NewReader(buf) + if strings.HasPrefix(part.Type, "text/") && part.Charset != "" { + r, err := charset.Reader(part.Charset, reader) + if err != nil { + w.w.Warnf("charset.Reader: %v", err) + } else { + reader = r + } + } + w.w.PostMessage(&types.MessageBodyPart{ + Message: types.RespondTo(msg), + Part: &models.MessageBodyPart{ + Reader: reader, + Uid: msg.Uid, + }, + }, nil) + + return nil +} + +func (w *JMAPWorker) handleFetchFullMessages(msg *types.FetchFullMessages) error { + for _, uid := range msg.Uids { + mail, err := w.cache.GetEmail(jmap.ID(uid)) + if err != nil { + return fmt.Errorf("bug: unknown message id %s: %w", uid, err) + } + buf, err := w.cache.GetBlob(mail.BlobID) + if err != nil { + rd, err := w.Download(mail.BlobID) + if err != nil { + return w.wrapDownloadError("full", mail.BlobID, err) + } + buf, err = io.ReadAll(rd) + rd.Close() + if err != nil { + return err + } + if err = w.cache.PutBlob(mail.BlobID, buf); err != nil { + w.w.Warnf("PutBlob: %s", err) + } + } + w.w.PostMessage(&types.FullMessage{ + Message: types.RespondTo(msg), + Content: &models.FullMessage{ + Reader: bytes.NewReader(buf), + Uid: uid, + }, + }, nil) + } + + return nil +} + +func (w *JMAPWorker) wrapDownloadError(prefix string, blobId jmap.ID, err error) error { + urlRepl := strings.NewReplacer( + "{accountId}", string(w.AccountId()), + "{blobId}", string(blobId), + "{type}", "application/octet-stream", + "{name}", "filename", + ) + url := urlRepl.Replace(w.client.Session.DownloadURL) + return fmt.Errorf("%s: %q %w", prefix, url, err) +} diff --git a/worker/jmap/jmap.go b/worker/jmap/jmap.go new file mode 100644 index 0000000..7320ec0 --- /dev/null +++ b/worker/jmap/jmap.go @@ -0,0 +1,179 @@ +package jmap + +import ( + "errors" + "fmt" + "sort" + "strings" + + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rockorager/go-jmap" + "git.sr.ht/~rockorager/go-jmap/mail" + "git.sr.ht/~rockorager/go-jmap/mail/email" + "git.sr.ht/~rockorager/go-jmap/mail/mailbox" + msgmail "github.com/emersion/go-message/mail" +) + +func (w *JMAPWorker) translateMsgInfo(m *email.Email) *models.MessageInfo { + env := &models.Envelope{ + Date: *m.ReceivedAt, + Subject: m.Subject, + From: translateAddrList(m.From), + ReplyTo: translateAddrList(m.ReplyTo), + To: translateAddrList(m.To), + Cc: translateAddrList(m.CC), + Bcc: translateAddrList(m.BCC), + MessageId: firstString(m.MessageID), + InReplyTo: firstString(m.InReplyTo), + } + labels := make([]string, 0, len(m.MailboxIDs)) + for id := range m.MailboxIDs { + if dir, ok := w.mbox2dir[id]; ok { + labels = append(labels, dir) + } + } + sort.Strings(labels) + + return &models.MessageInfo{ + Envelope: env, + Flags: keywordsToFlags(m.Keywords), + Uid: models.UID(m.ID), + BodyStructure: translateBodyStructure(m.BodyStructure), + RFC822Headers: translateJMAPHeader(m.Headers), + Refs: m.References, + Labels: labels, + Size: uint32(m.Size), + InternalDate: *m.ReceivedAt, + } +} + +func translateJMAPHeader(headers []*email.Header) *msgmail.Header { + hdr := new(msgmail.Header) + for _, h := range headers { + raw := fmt.Sprintf("%s:%s\r\n", h.Name, h.Value) + hdr.AddRaw([]byte(raw)) + } + return hdr +} + +func flagsToKeywords(flags models.Flags) map[string]bool { + kw := make(map[string]bool) + if flags.Has(models.SeenFlag) { + kw["$seen"] = true + } + if flags.Has(models.AnsweredFlag) { + kw["$answered"] = true + } + if flags.Has(models.FlaggedFlag) { + kw["$flagged"] = true + } + if flags.Has(models.DraftFlag) { + kw["$draft"] = true + } + return kw +} + +func keywordsToFlags(kw map[string]bool) models.Flags { + var f models.Flags + for k, v := range kw { + if v { + switch k { + case "$seen": + f |= models.SeenFlag + case "$answered": + f |= models.AnsweredFlag + case "$flagged": + f |= models.FlaggedFlag + case "$draft": + f |= models.DraftFlag + } + } + } + return f +} + +func (w *JMAPWorker) MailboxPath(mbox *mailbox.Mailbox) string { + if mbox == nil { + return "" + } + if mbox.ParentID == "" { + return mbox.Name + } + parent, err := w.cache.GetMailbox(mbox.ParentID) + if err != nil { + w.w.Warnf("MailboxPath/GetMailbox: %s", err) + return mbox.Name + } + return w.MailboxPath(parent) + "/" + mbox.Name +} + +var jmapRole2aerc = map[mailbox.Role]models.Role{ + mailbox.RoleAll: models.AllRole, + mailbox.RoleArchive: models.ArchiveRole, + mailbox.RoleDrafts: models.DraftsRole, + mailbox.RoleInbox: models.InboxRole, + mailbox.RoleJunk: models.JunkRole, + mailbox.RoleSent: models.SentRole, + mailbox.RoleTrash: models.TrashRole, +} + +func firstString(s []string) string { + if len(s) == 0 { + return "" + } + return s[0] +} + +func translateAddrList(addrs []*mail.Address) []*msgmail.Address { + res := make([]*msgmail.Address, 0, len(addrs)) + for _, a := range addrs { + res = append(res, &msgmail.Address{Name: a.Name, Address: a.Email}) + } + return res +} + +func translateBodyStructure(part *email.BodyPart) *models.BodyStructure { + bs := &models.BodyStructure{ + Description: part.Name, + Encoding: part.Charset, + Params: map[string]string{ + "name": part.Name, + "charset": part.Charset, + }, + Disposition: part.Disposition, + DispositionParams: map[string]string{ + "filename": part.Name, + }, + } + bs.MIMEType, bs.MIMESubType, _ = strings.Cut(part.Type, "/") + for _, sub := range part.SubParts { + bs.Parts = append(bs.Parts, translateBodyStructure(sub)) + } + return bs +} + +func wrapSetError(err *jmap.SetError) error { + var s string + if err.Description != nil { + s = *err.Description + } else { + s = err.Type + if err.Properties != nil { + s += fmt.Sprintf(" %v", *err.Properties) + } + if s == "invalidProperties: [mailboxIds]" { + s = "a message must belong to one or more mailboxes" + } + } + return errors.New(s) +} + +func wrapMethodError(err *jmap.MethodError) error { + var s string + if err.Description != nil { + s = *err.Description + } else { + s = err.Type + } + return errors.New(s) +} diff --git a/worker/jmap/push.go b/worker/jmap/push.go new file mode 100644 index 0000000..d202b62 --- /dev/null +++ b/worker/jmap/push.go @@ -0,0 +1,484 @@ +package jmap + +import ( + "fmt" + "sort" + "time" + + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/jmap/cache" + "git.sr.ht/~rjarry/aerc/worker/types" + "git.sr.ht/~rockorager/go-jmap" + "git.sr.ht/~rockorager/go-jmap/core/push" + "git.sr.ht/~rockorager/go-jmap/mail/email" + "git.sr.ht/~rockorager/go-jmap/mail/mailbox" + "git.sr.ht/~rockorager/go-jmap/mail/thread" +) + +func (w *JMAPWorker) monitorChanges() { + defer log.PanicHandler() + + events := push.EventSource{ + Client: w.client, + Handler: w.handleChange, + Ping: uint(w.config.serverPing.Seconds()), + } + + w.stop = make(chan struct{}) + go func() { + defer log.PanicHandler() + <-w.stop + w.w.Errorf("listen stopping") + w.stop = nil + events.Close() + }() + + for w.stop != nil { + w.w.Debugf("listening for changes") + err := events.Listen() + if err != nil { + w.w.PostMessage(&types.Error{ + Error: fmt.Errorf("jmap listen: %w", err), + }, nil) + time.Sleep(5 * time.Second) + } + } +} + +func (w *JMAPWorker) handleChange(s *jmap.StateChange) { + changed, ok := s.Changed[w.AccountId()] + if !ok { + return + } + w.w.Debugf("state change %#v", changed) + w.changes <- changed +} + +func (w *JMAPWorker) refresh(newState jmap.TypeState) error { + var req jmap.Request + + mboxState, err := w.cache.GetMailboxState() + if err != nil { + w.w.Debugf("GetMailboxState: %s", err) + } + if mboxState != "" && newState["Mailbox"] != mboxState { + callID := req.Invoke(&mailbox.Changes{ + Account: w.AccountId(), + SinceState: mboxState, + }) + req.Invoke(&mailbox.Get{ + Account: w.AccountId(), + ReferenceIDs: &jmap.ResultReference{ + ResultOf: callID, + Name: "Mailbox/changes", + Path: "/created", + }, + }) + req.Invoke(&mailbox.Get{ + Account: w.AccountId(), + ReferenceIDs: &jmap.ResultReference{ + ResultOf: callID, + Name: "Mailbox/changes", + Path: "/updated", + }, + }) + } + + emailState, err := w.cache.GetEmailState() + if err != nil { + w.w.Debugf("GetEmailState: %s", err) + } + ids, _ := w.cache.GetMailboxList() + mboxes := make(map[jmap.ID]*mailbox.Mailbox) + for _, id := range ids { + mbox, err := w.cache.GetMailbox(id) + if err != nil { + w.w.Warnf("GetMailbox: %s", err) + continue + } + if mbox.Role == mailbox.RoleArchive && w.config.useLabels { + mboxes[""] = &mailbox.Mailbox{ + Name: w.config.allMail, + Role: mailbox.RoleAll, + } + } else { + mboxes[id] = mbox + } + } + emailUpdated := "" + emailCreated := "" + if emailState != "" && newState["Email"] != emailState { + callID := req.Invoke(&email.Changes{ + Account: w.AccountId(), + SinceState: emailState, + }) + emailUpdated = req.Invoke(&email.Get{ + Account: w.AccountId(), + Properties: headersProperties, + ReferenceIDs: &jmap.ResultReference{ + ResultOf: callID, + Name: "Email/changes", + Path: "/updated", + }, + }) + + emailCreated = req.Invoke(&email.Get{ + Account: w.AccountId(), + Properties: headersProperties, + ReferenceIDs: &jmap.ResultReference{ + ResultOf: callID, + Name: "Email/changes", + Path: "/created", + }, + }) + } + + threadState, err := w.cache.GetThreadState() + if err != nil { + w.w.Debugf("GetThreadState: %s", err) + } + if threadState != "" && newState["Thread"] != threadState { + callID := req.Invoke(&thread.Changes{ + Account: w.AccountId(), + SinceState: threadState, + }) + req.Invoke(&thread.Get{ + Account: w.AccountId(), + ReferenceIDs: &jmap.ResultReference{ + ResultOf: callID, + Name: "Thread/changes", + Path: "/created", + }, + }) + req.Invoke(&thread.Get{ + Account: w.AccountId(), + ReferenceIDs: &jmap.ResultReference{ + ResultOf: callID, + Name: "Thread/changes", + Path: "/updated", + }, + }) + } + + if len(req.Calls) == 0 { + return nil + } + + resp, err := w.Do(&req) + if err != nil { + return err + } + + var changedMboxIds []jmap.ID + var labelsChanged bool + // threadEmails are email IDs from threads which changed or were + // created + var threadEmails []jmap.ID + + for _, inv := range resp.Responses { + switch r := inv.Args.(type) { + case *mailbox.ChangesResponse: + for _, id := range r.Destroyed { + dir, ok := w.mbox2dir[id] + if ok { + w.w.PostMessage(&types.RemoveDirectory{ + Directory: dir, + }, nil) + } + w.deleteMbox(id) + err = w.cache.DeleteMailbox(id) + if err != nil { + w.w.Warnf("DeleteMailbox: %s", err) + } + labelsChanged = true + } + err = w.cache.PutMailboxState(r.NewState) + if err != nil { + w.w.Warnf("PutMailboxState: %s", err) + } + + case *mailbox.GetResponse: + for _, mbox := range r.List { + changedMboxIds = append(changedMboxIds, mbox.ID) + mboxes[mbox.ID] = mbox + err = w.cache.PutMailbox(mbox.ID, mbox) + if err != nil { + w.w.Warnf("PutMailbox: %s", err) + } + } + err = w.cache.PutMailboxState(r.State) + if err != nil { + w.w.Warnf("PutMailboxState: %s", err) + } + + case *thread.ChangesResponse: + for _, id := range r.Destroyed { + err = w.cache.DeleteThread(id) + if err != nil { + w.w.Warnf("DeleteThread: %s", err) + } + } + err = w.cache.PutThreadState(r.NewState) + if err != nil { + w.w.Warnf("PutThreadState: %s", err) + } + + case *thread.GetResponse: + for _, thread := range r.List { + err = w.cache.PutThread(thread.ID, thread.EmailIDs) + if err != nil { + w.w.Warnf("PutThread: %s", err) + } + // We keep the list of all emails and check in a + // subsequent request which ones we need to + // fetch + threadEmails = append(threadEmails, thread.EmailIDs...) + } + err = w.cache.PutThreadState(r.State) + if err != nil { + w.w.Warnf("PutThreadState: %s", err) + } + + case *email.GetResponse: + switch inv.CallID { + case emailUpdated: + for _, m := range r.List { + err = w.cache.PutEmail(m.ID, m) + if err != nil { + w.w.Warnf("PutEmail: %s", err) + } + // Send an updated message info if this + // is part of our selected mailbox + if m.MailboxIDs[w.selectedMbox] { + w.w.PostMessage(&types.MessageInfo{ + Info: w.translateMsgInfo(m), + }, nil) + } + } + err = w.cache.PutEmailState(r.State) + if err != nil { + w.w.Warnf("PutEmailState: %s", err) + } + case emailCreated: + for _, m := range r.List { + err = w.cache.PutEmail(m.ID, m) + if err != nil { + w.w.Warnf("PutEmail: %s", err) + } + info := w.translateMsgInfo(m) + // Set recent on created messages so we + // get a notification + info.Flags |= models.RecentFlag + w.w.PostMessage(&types.MessageInfo{ + Info: info, + }, nil) + } + err = w.cache.PutEmailState(r.State) + if err != nil { + w.w.Warnf("PutEmailState: %s", err) + } + } + + case *jmap.MethodError: + w.w.Errorf("%s: %s", wrapMethodError(r)) + } + } + + var updatedMboxes []jmap.ID + for _, id := range changedMboxIds { + mbox := mboxes[id] + if mbox.Role == mailbox.RoleArchive && w.config.useLabels { + continue + } + newDir := w.MailboxPath(mbox) + dir, ok := w.mbox2dir[id] + if ok { + // updated + if newDir == dir { + w.deleteMbox(id) + w.addMbox(mbox, dir) + w.w.PostMessage(&types.DirectoryInfo{ + Info: &models.DirectoryInfo{ + Name: dir, + Exists: int(mbox.TotalEmails), + Unseen: int(mbox.UnreadEmails), + }, + }, nil) + + updatedMboxes = append(updatedMboxes, id) + } else { + // renamed mailbox + w.deleteMbox(id) + w.w.PostMessage(&types.RemoveDirectory{ + Directory: dir, + }, nil) + dir = newDir + } + } + // new mailbox + w.addMbox(mbox, dir) + w.w.PostMessage(&types.Directory{ + Dir: &models.Directory{ + Name: dir, + Exists: int(mbox.TotalEmails), + Unseen: int(mbox.UnreadEmails), + Role: jmapRole2aerc[mbox.Role], + }, + }, nil) + labelsChanged = true + } + + if w.config.useLabels && labelsChanged { + labels := make([]string, 0, len(w.dir2mbox)) + for dir := range w.dir2mbox { + labels = append(labels, dir) + } + sort.Strings(labels) + w.w.PostMessage(&types.LabelList{Labels: labels}, nil) + } + + return w.refreshQueriesAndThreads(updatedMboxes, threadEmails) +} + +// refreshQueriesAndThreads updates the cached query for any mailbox which was updated +func (w *JMAPWorker) refreshQueriesAndThreads( + updatedMboxes []jmap.ID, + threadEmails []jmap.ID, +) error { + if len(updatedMboxes) == 0 && len(threadEmails) == 0 { + return nil + } + + var req jmap.Request + queryChangesCalls := make(map[string]jmap.ID) + folderContents := make(map[jmap.ID]*cache.FolderContents) + + for _, id := range updatedMboxes { + contents, err := w.cache.GetFolderContents(id) + if err != nil { + continue + } + callID := req.Invoke(&email.QueryChanges{ + Account: w.AccountId(), + Filter: w.translateSearch(id, contents.Filter), + Sort: translateSort(contents.Sort), + SinceQueryState: contents.QueryState, + }) + queryChangesCalls[callID] = id + folderContents[id] = contents + } + + emailsToFetch := []jmap.ID{} + for _, id := range threadEmails { + if w.cache.HasEmail(id) { + continue + } + emailsToFetch = append(emailsToFetch, id) + } + + req.Invoke(&email.Get{ + Account: w.AccountId(), + Properties: headersProperties, + IDs: emailsToFetch, + }) + + resp, err := w.Do(&req) + if err != nil { + return err + } + + for _, inv := range resp.Responses { + switch r := inv.Args.(type) { + case *email.QueryChangesResponse: + mboxId := queryChangesCalls[inv.CallID] + contents := folderContents[mboxId] + + removed := make(map[jmap.ID]bool) + for _, id := range r.Removed { + removed[id] = true + } + added := make(map[int]jmap.ID) + for _, add := range r.Added { + added[int(add.Index)] = add.ID + } + w.w.Debugf("%q: %d added, %d removed", + w.mbox2dir[mboxId], len(added), len(removed)) + n := len(contents.MessageIDs) - len(removed) + len(added) + if n < 0 { + w.w.Errorf("bug: invalid folder contents state") + err = w.cache.DeleteFolderContents(mboxId) + if err != nil { + w.w.Warnf("DeleteFolderContents: %s", err) + } + continue + } + ids := make([]jmap.ID, 0, n) + i := 0 + for _, id := range contents.MessageIDs { + if removed[id] { + continue + } + if addedId, ok := added[i]; ok { + ids = append(ids, addedId) + delete(added, i) + i += 1 + } + ids = append(ids, id) + i += 1 + } + for _, id := range added { + ids = append(ids, id) + } + contents.MessageIDs = ids + contents.QueryState = r.NewQueryState + + err = w.cache.PutFolderContents(mboxId, contents) + if err != nil { + w.w.Warnf("PutFolderContents: %s", err) + } + + if w.selectedMbox == mboxId { + uids := make([]models.UID, 0, len(ids)) + for _, id := range ids { + uids = append(uids, models.UID(id)) + } + w.w.PostMessage(&types.DirectoryContents{ + Uids: uids, + }, nil) + } + + case *email.GetResponse: + for _, m := range r.List { + err = w.cache.PutEmail(m.ID, m) + if err != nil { + w.w.Warnf("PutEmail: %s", err) + } + // Send an updated message info if this + // is part of our selected mailbox + if m.MailboxIDs[w.selectedMbox] { + w.w.PostMessage(&types.MessageInfo{ + Info: w.translateMsgInfo(m), + }, nil) + } + } + err = w.cache.PutEmailState(r.State) + if err != nil { + w.w.Warnf("PutEmailState: %s", err) + } + + case *jmap.MethodError: + w.w.Errorf("%s: %s", wrapMethodError(r)) + if inv.Name == "Email/queryChanges" { + id := queryChangesCalls[inv.CallID] + w.w.Infof("flushing %q contents from cache", + w.mbox2dir[id]) + err := w.cache.DeleteFolderContents(id) + if err != nil { + w.w.Warnf("DeleteFolderContents: %s", err) + } + } + } + } + return nil +} diff --git a/worker/jmap/search.go b/worker/jmap/search.go new file mode 100644 index 0000000..024cc18 --- /dev/null +++ b/worker/jmap/search.go @@ -0,0 +1,101 @@ +package jmap + +import ( + "strings" + + "git.sr.ht/~rjarry/aerc/worker/types" + "git.sr.ht/~rockorager/go-jmap" + "git.sr.ht/~rockorager/go-jmap/mail/email" + "git.sr.ht/~rockorager/go-jmap/mail/mailbox" +) + +func (w *JMAPWorker) translateSearch( + mbox jmap.ID, criteria *types.SearchCriteria, +) email.Filter { + cond := new(email.FilterCondition) + + if mbox == "" { + // all mail virtual folder: display all but trash and spam + var mboxes []jmap.ID + if id, ok := w.roles[mailbox.RoleJunk]; ok { + mboxes = append(mboxes, id) + } + if id, ok := w.roles[mailbox.RoleTrash]; ok { + mboxes = append(mboxes, id) + } + cond.InMailboxOtherThan = mboxes + } else { + cond.InMailbox = mbox + } + if criteria == nil { + return cond + } + + // dates + if !criteria.StartDate.IsZero() { + cond.After = &criteria.StartDate + } + if !criteria.EndDate.IsZero() { + cond.Before = &criteria.EndDate + } + + // general search terms + terms := strings.Join(criteria.Terms, " ") + switch { + case criteria.SearchAll: + cond.Text = terms + case criteria.SearchBody: + cond.Body = terms + default: + cond.Subject = terms + } + + filter := &email.FilterOperator{Operator: jmap.OperatorAND} + filter.Conditions = append(filter.Conditions, cond) + + // keywords/flags + for kw := range flagsToKeywords(criteria.WithFlags) { + filter.Conditions = append(filter.Conditions, + &email.FilterCondition{HasKeyword: kw}) + } + for kw := range flagsToKeywords(criteria.WithoutFlags) { + filter.Conditions = append(filter.Conditions, + &email.FilterCondition{NotKeyword: kw}) + } + + // recipients + addrs := &email.FilterOperator{ + Operator: jmap.OperatorOR, + } + for _, from := range criteria.From { + addrs.Conditions = append(addrs.Conditions, + &email.FilterCondition{From: from}) + } + for _, to := range criteria.To { + addrs.Conditions = append(addrs.Conditions, + &email.FilterCondition{To: to}) + } + for _, cc := range criteria.Cc { + addrs.Conditions = append(addrs.Conditions, + &email.FilterCondition{Cc: cc}) + } + if len(addrs.Conditions) > 0 { + filter.Conditions = append(filter.Conditions, addrs) + } + + // specific headers + headers := &email.FilterOperator{ + Operator: jmap.OperatorAND, + } + for h, values := range criteria.Headers { + for _, v := range values { + headers.Conditions = append(headers.Conditions, + &email.FilterCondition{Header: []string{h, v}}) + } + } + if len(headers.Conditions) > 0 { + filter.Conditions = append(filter.Conditions, headers) + } + + return filter +} diff --git a/worker/jmap/send.go b/worker/jmap/send.go new file mode 100644 index 0000000..ee62635 --- /dev/null +++ b/worker/jmap/send.go @@ -0,0 +1,158 @@ +package jmap + +import ( + "fmt" + "io" + "strings" + + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/worker/types" + "git.sr.ht/~rockorager/go-jmap" + "git.sr.ht/~rockorager/go-jmap/mail/email" + "git.sr.ht/~rockorager/go-jmap/mail/emailsubmission" + "git.sr.ht/~rockorager/go-jmap/mail/mailbox" + "github.com/emersion/go-message/mail" +) + +func (w *JMAPWorker) handleStartSend(msg *types.StartSendingMessage) error { + reader, writer := io.Pipe() + send := &jmapSendWriter{writer: writer, done: make(chan error)} + + w.w.PostMessage(&types.MessageWriter{ + Message: types.RespondTo(msg), + Writer: send, + }, nil) + + go func() { + defer log.PanicHandler() + defer close(send.done) + + identity, err := w.getSenderIdentity(msg.From) + if err != nil { + send.done <- err + return + } + + blob, err := w.Upload(reader) + if err != nil { + send.done <- err + return + } + + var req jmap.Request + + // Import the blob into drafts + req.Invoke(&email.Import{ + Account: w.AccountId(), + Emails: map[string]*email.EmailImport{ + "aerc": { + BlobID: blob.ID, + MailboxIDs: map[jmap.ID]bool{ + w.roles[mailbox.RoleDrafts]: true, + }, + Keywords: map[string]bool{ + "$draft": true, + "$seen": true, + }, + }, + }, + }) + + from := &emailsubmission.Address{Email: msg.From.Address} + var rcpts []*emailsubmission.Address + for _, address := range msg.Rcpts { + rcpts = append(rcpts, &emailsubmission.Address{ + Email: address.Address, + }) + } + envelope := &emailsubmission.Envelope{MailFrom: from, RcptTo: rcpts} + onSuccess := jmap.Patch{ + "keywords/$draft": nil, + w.rolePatch(mailbox.RoleSent): true, + w.rolePatch(mailbox.RoleDrafts): nil, + } + for _, dir := range msg.CopyTo { + mbox, ok := w.dir2mbox[dir] + if ok && mbox != w.roles[mailbox.RoleSent] { + onSuccess[w.mboxPatch(mbox)] = true + } + } + // Create the submission + req.Invoke(&emailsubmission.Set{ + Account: w.AccountId(), + Create: map[jmap.ID]*emailsubmission.EmailSubmission{ + "sub": { + IdentityID: identity, + EmailID: "#aerc", + Envelope: envelope, + }, + }, + OnSuccessUpdateEmail: map[jmap.ID]jmap.Patch{ + "#sub": onSuccess, + }, + }) + + resp, err := w.Do(&req) + if err != nil { + send.done <- err + return + } + + for _, inv := range resp.Responses { + switch r := inv.Args.(type) { + case *email.ImportResponse: + if err, ok := r.NotCreated["aerc"]; ok { + send.done <- wrapSetError(err) + return + } + case *emailsubmission.SetResponse: + if err, ok := r.NotCreated["sub"]; ok { + send.done <- wrapSetError(err) + return + } + case *jmap.MethodError: + send.done <- wrapMethodError(r) + return + } + } + }() + + return nil +} + +type jmapSendWriter struct { + writer *io.PipeWriter + done chan error +} + +func (w *jmapSendWriter) Write(data []byte) (int, error) { + return w.writer.Write(data) +} + +func (w *jmapSendWriter) Close() error { + writeErr := w.writer.Close() + sendErr := <-w.done + if writeErr != nil { + return writeErr + } + return sendErr +} + +func (w *JMAPWorker) getSenderIdentity(from *mail.Address) (jmap.ID, error) { + if len(w.identities) == 0 { + if err := w.GetIdentities(); err != nil { + return "", err + } + } + name, domain, _ := strings.Cut(from.Address, "@") + for _, ident := range w.identities { + n, d, _ := strings.Cut(ident.Email, "@") + switch { + case n == name && d == domain: + fallthrough + case n == "*" && d == domain: + return ident.ID, nil + } + } + return "", fmt.Errorf("no identity found for address: %s@%s", name, domain) +} diff --git a/worker/jmap/set.go b/worker/jmap/set.go new file mode 100644 index 0000000..b6bae38 --- /dev/null +++ b/worker/jmap/set.go @@ -0,0 +1,263 @@ +package jmap + +import ( + "fmt" + + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/types" + "git.sr.ht/~rockorager/go-jmap" + "git.sr.ht/~rockorager/go-jmap/mail/email" + "git.sr.ht/~rockorager/go-jmap/mail/mailbox" +) + +func (w *JMAPWorker) updateFlags(uids []models.UID, flags models.Flags, enable bool) error { + var req jmap.Request + patches := make(map[jmap.ID]jmap.Patch) + + for _, uid := range uids { + patch := jmap.Patch{} + for kw := range flagsToKeywords(flags) { + path := fmt.Sprintf("keywords/%s", kw) + if enable { + patch[path] = true + } else { + patch[path] = nil + } + } + patches[jmap.ID(uid)] = patch + } + + req.Invoke(&email.Set{ + Account: w.AccountId(), + Update: patches, + }) + + resp, err := w.Do(&req) + if err != nil { + return err + } + + err = checkNotUpdated(resp) + if err != nil { + return err + } + + // If we didn't get an update error, all methods succeeded. We can + // update the cache and UI now. We don't update the email state so that + // we still grab an updated set from the update channel + for _, uid := range uids { + jid := jmap.ID(uid) + m, err := w.cache.GetEmail(jid) + if err != nil { + // We'll get this from the update channel + continue + } + if enable { + for kw := range flagsToKeywords(flags) { + m.Keywords[kw] = true + } + } else { + for kw := range flagsToKeywords(flags) { + delete(m.Keywords, kw) + } + } + err = w.cache.PutEmail(jid, m) + if err != nil { + w.w.Warnf("PutEmail: %s", err) + } + // Get the UI updated immediately + w.w.PostMessage(&types.MessageInfo{ + Info: w.translateMsgInfo(m), + }, nil) + } + + return nil +} + +func (w *JMAPWorker) moveCopy(uids []models.UID, destDir string, deleteSrc bool) error { + var req jmap.Request + var destMbox jmap.ID + var destroy []jmap.ID + var ok bool + + patches := make(map[jmap.ID]jmap.Patch) + + destMbox, ok = w.dir2mbox[destDir] + if !ok && destDir != "" { + return fmt.Errorf("unknown destination mailbox") + } + if destMbox != "" && destMbox == w.selectedMbox { + return fmt.Errorf("cannot move to current mailbox") + } + + for _, uid := range uids { + dest := destMbox + mail, err := w.cache.GetEmail(jmap.ID(uid)) + if err != nil { + return fmt.Errorf("bug: unknown message id %s: %w", uid, err) + } + + patch := w.moveCopyPatch(mail, dest, deleteSrc) + if len(patch) == 0 { + destroy = append(destroy, mail.ID) + w.w.Debugf("destroying <%s>", mail.MessageID[0]) + } else { + patches[jmap.ID(uid)] = patch + } + } + + req.Invoke(&email.Set{ + Account: w.AccountId(), + Update: patches, + Destroy: destroy, + }) + + resp, err := w.Do(&req) + if err != nil { + return err + } + + return checkNotUpdated(resp) +} + +func (w *JMAPWorker) moveCopyPatch( + mail *email.Email, dest jmap.ID, deleteSrc bool, +) jmap.Patch { + patch := jmap.Patch{} + + if dest == "" && deleteSrc && len(mail.MailboxIDs) == 1 { + dest = w.roles[mailbox.RoleTrash] + } + if dest != "" && dest != w.selectedMbox { + d := w.mbox2dir[dest] + if deleteSrc { + w.w.Debugf("moving <%s> to %q", mail.MessageID[0], d) + } else { + w.w.Debugf("copying <%s> to %q", mail.MessageID[0], d) + } + patch[w.mboxPatch(dest)] = true + } + if deleteSrc && len(patch) > 0 { + switch { + case w.selectedMbox != "": + patch[w.mboxPatch(w.selectedMbox)] = nil + case len(mail.MailboxIDs) == 1: + // In "all mail" virtual mailbox and email is in + // a single mailbox, "Move" it to the specified + // destination + patch = jmap.Patch{"mailboxIds": []jmap.ID{dest}} + default: + // In "all mail" virtual mailbox and email is in + // multiple mailboxes. Since we cannot know what mailbox + // to remove, try at least to remove role=inbox. + patch[w.rolePatch(mailbox.RoleInbox)] = nil + } + } + + return patch +} + +func (w *JMAPWorker) mboxPatch(mbox jmap.ID) string { + return fmt.Sprintf("mailboxIds/%s", mbox) +} + +func (w *JMAPWorker) rolePatch(role mailbox.Role) string { + return fmt.Sprintf("mailboxIds/%s", w.roles[role]) +} + +func (w *JMAPWorker) handleModifyLabels(msg *types.ModifyLabels) error { + var req jmap.Request + patch := jmap.Patch{} + + for _, a := range msg.Add { + mboxId, ok := w.dir2mbox[a] + if !ok { + return fmt.Errorf("unknown label: %q", a) + } + patch[w.mboxPatch(mboxId)] = true + } + for _, r := range msg.Remove { + mboxId, ok := w.dir2mbox[r] + if !ok { + return fmt.Errorf("unknown label: %q", r) + } + patch[w.mboxPatch(mboxId)] = nil + } + + patches := make(map[jmap.ID]jmap.Patch) + + for _, uid := range msg.Uids { + patches[jmap.ID(uid)] = patch + } + + req.Invoke(&email.Set{ + Account: w.AccountId(), + Update: patches, + }) + + resp, err := w.Do(&req) + if err != nil { + return err + } + + return checkNotUpdated(resp) +} + +func checkNotUpdated(resp *jmap.Response) error { + for _, inv := range resp.Responses { + switch r := inv.Args.(type) { + case *email.SetResponse: + for _, err := range r.NotUpdated { + return wrapSetError(err) + } + case *jmap.MethodError: + return wrapMethodError(r) + } + } + return nil +} + +func (w *JMAPWorker) handleAppendMessage(msg *types.AppendMessage) error { + dest, ok := w.dir2mbox[msg.Destination] + if !ok { + return fmt.Errorf("unknown destination mailbox") + } + + // Upload the message + blob, err := w.Upload(msg.Reader) + if err != nil { + return err + } + + var req jmap.Request + + // Import the blob into specified directory + req.Invoke(&email.Import{ + Account: w.AccountId(), + Emails: map[string]*email.EmailImport{ + "aerc": { + BlobID: blob.ID, + MailboxIDs: map[jmap.ID]bool{dest: true}, + Keywords: flagsToKeywords(msg.Flags), + }, + }, + }) + + resp, err := w.Do(&req) + if err != nil { + return err + } + + for _, inv := range resp.Responses { + switch r := inv.Args.(type) { + case *email.ImportResponse: + if err, ok := r.NotCreated["aerc"]; ok { + return wrapSetError(err) + } + case *jmap.MethodError: + return wrapMethodError(r) + } + } + + return nil +} diff --git a/worker/jmap/threads.go b/worker/jmap/threads.go new file mode 100644 index 0000000..97e1a10 --- /dev/null +++ b/worker/jmap/threads.go @@ -0,0 +1,63 @@ +package jmap + +import ( + "git.sr.ht/~rockorager/go-jmap" + "git.sr.ht/~rockorager/go-jmap/mail/email" + "git.sr.ht/~rockorager/go-jmap/mail/thread" +) + +func (w *JMAPWorker) fetchEntireThreads(threads []jmap.ID) ([]*email.Email, error) { + var req jmap.Request + + if len(threads) == 0 { + return []*email.Email{}, nil + } + + threadGetId := req.Invoke(&thread.Get{ + Account: w.AccountId(), + IDs: threads, + }) + + // Opportunistically fetch all emails in this thread. We could wait for + // the result, check which ones we don't have, then fetch only those. + // However we can do this all in a single request which ends up being + // faster than two requests for most contexts + req.Invoke(&email.Get{ + Account: w.AccountId(), + ReferenceIDs: &jmap.ResultReference{ + ResultOf: threadGetId, + Name: "Thread/get", + Path: "/list/*/emailIds", + }, + Properties: headersProperties, + }) + + resp, err := w.Do(&req) + if err != nil { + return nil, err + } + + emailsToReturn := make([]*email.Email, 0) + for _, inv := range resp.Responses { + switch r := inv.Args.(type) { + case *thread.GetResponse: + if err = w.cache.PutThreadState(r.State); err != nil { + w.w.Warnf("PutThreadState: %s", err) + } + for _, thread := range r.List { + if err = w.cache.PutThread(thread.ID, thread.EmailIDs); err != nil { + w.w.Warnf("PutThread: %s", err) + } + } + case *email.GetResponse: + emailsToReturn = append(emailsToReturn, r.List...) + if err = w.cache.PutEmailState(r.State); err != nil { + w.w.Warnf("PutEmailState: %s", err) + } + case *jmap.MethodError: + return nil, wrapMethodError(r) + } + } + + return emailsToReturn, nil +} diff --git a/worker/jmap/worker.go b/worker/jmap/worker.go new file mode 100644 index 0000000..58883e1 --- /dev/null +++ b/worker/jmap/worker.go @@ -0,0 +1,197 @@ +package jmap + +import ( + "errors" + "net/url" + "time" + + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/handlers" + "git.sr.ht/~rjarry/aerc/worker/jmap/cache" + "git.sr.ht/~rjarry/aerc/worker/types" + "git.sr.ht/~rockorager/go-jmap" + "git.sr.ht/~rockorager/go-jmap/mail/identity" + "git.sr.ht/~rockorager/go-jmap/mail/mailbox" +) + +func init() { + handlers.RegisterWorkerFactory("jmap", NewJMAPWorker) +} + +var ( + errNoop error = errors.New("noop") + errUnsupported error = errors.New("unsupported") +) + +type JMAPWorker struct { + config struct { + account *config.AccountConfig + endpoint string + oauth bool + user *url.Userinfo + cacheState bool + cacheBlobs bool + serverPing time.Duration + useLabels bool + allMail string + } + + w *types.Worker + client *jmap.Client + cache *cache.JMAPCache + + selectedMbox jmap.ID + dir2mbox map[string]jmap.ID + mbox2dir map[jmap.ID]string + roles map[mailbox.Role]jmap.ID + identities map[string]*identity.Identity + + changes chan jmap.TypeState + stop chan struct{} +} + +func NewJMAPWorker(worker *types.Worker) (types.Backend, error) { + return &JMAPWorker{ + w: worker, + roles: make(map[mailbox.Role]jmap.ID), + dir2mbox: make(map[string]jmap.ID), + mbox2dir: make(map[jmap.ID]string), + identities: make(map[string]*identity.Identity), + changes: make(chan jmap.TypeState), + }, nil +} + +func (w *JMAPWorker) addMbox(mbox *mailbox.Mailbox, dir string) { + w.mbox2dir[mbox.ID] = dir + w.dir2mbox[dir] = mbox.ID + w.roles[mbox.Role] = mbox.ID +} + +func (w *JMAPWorker) deleteMbox(id jmap.ID) { + var dir string + var role mailbox.Role + + delete(w.mbox2dir, id) + for d, i := range w.dir2mbox { + if i == id { + dir = d + break + } + } + delete(w.dir2mbox, dir) + for r, i := range w.roles { + if i == id { + role = r + break + } + } + delete(w.roles, role) +} + +var capas = models.Capabilities{Sort: true, Thread: false} + +func (w *JMAPWorker) Capabilities() *models.Capabilities { + return &capas +} + +func (w *JMAPWorker) PathSeparator() string { + return "/" +} + +func (w *JMAPWorker) handleMessage(msg types.WorkerMessage) error { + switch msg := msg.(type) { + case *types.Configure: + return w.handleConfigure(msg) + case *types.Connect: + if w.stop != nil { + return errors.New("already connected") + } + return w.handleConnect(msg) + case *types.Reconnect: + if w.stop == nil { + return errors.New("not connected") + } + close(w.stop) + return w.handleConnect(&types.Connect{Message: msg.Message}) + case *types.Disconnect: + if w.stop == nil { + return errors.New("not connected") + } + close(w.stop) + return nil + case *types.ListDirectories: + return w.handleListDirectories(msg) + case *types.OpenDirectory: + return w.handleOpenDirectory(msg) + case *types.FetchDirectoryContents: + return w.handleFetchDirectoryContents(msg) + case *types.SearchDirectory: + return w.handleSearchDirectory(msg) + case *types.CreateDirectory: + return w.handleCreateDirectory(msg) + case *types.RemoveDirectory: + return w.handleRemoveDirectory(msg) + case *types.FetchMessageHeaders: + return w.handleFetchMessageHeaders(msg) + case *types.FetchMessageBodyPart: + return w.handleFetchMessageBodyPart(msg) + case *types.FetchFullMessages: + return w.handleFetchFullMessages(msg) + case *types.FlagMessages: + return w.updateFlags(msg.Uids, msg.Flags, msg.Enable) + case *types.AnsweredMessages: + return w.updateFlags(msg.Uids, models.AnsweredFlag, msg.Answered) + case *types.DeleteMessages: + return w.moveCopy(msg.Uids, "", true) + case *types.CopyMessages: + return w.moveCopy(msg.Uids, msg.Destination, false) + case *types.MoveMessages: + return w.moveCopy(msg.Uids, msg.Destination, true) + case *types.ModifyLabels: + if w.config.useLabels { + return w.handleModifyLabels(msg) + } + case *types.AppendMessage: + return w.handleAppendMessage(msg) + case *types.StartSendingMessage: + return w.handleStartSend(msg) + } + return errUnsupported +} + +func (w *JMAPWorker) Run() { + for { + select { + case change := <-w.changes: + err := w.refresh(change) + if err != nil { + w.w.Errorf("refresh: %s", err) + } + case msg := <-w.w.Actions(): + msg = w.w.ProcessAction(msg) + err := w.handleMessage(msg) + switch { + case errors.Is(err, errNoop): + // Operation did not have any effect. + // Do *NOT* send a Done message. + break + case errors.Is(err, errUnsupported): + w.w.PostMessage(&types.Unsupported{ + Message: types.RespondTo(msg), + }, nil) + case err != nil: + w.w.PostMessage(&types.Error{ + Message: types.RespondTo(msg), + Error: err, + }, nil) + default: // err == nil + // Operation is finished. + // Send a Done message. + w.w.PostMessage(&types.Done{ + Message: types.RespondTo(msg), + }, nil) + } + } + } +} diff --git a/worker/lib/foldermap.go b/worker/lib/foldermap.go new file mode 100644 index 0000000..4fd1597 --- /dev/null +++ b/worker/lib/foldermap.go @@ -0,0 +1,34 @@ +package lib + +import ( + "fmt" + "io" + + "github.com/go-ini/ini" +) + +func ParseFolderMap(r io.Reader) (map[string]string, []string, error) { + cfg, err := ini.Load(r) + if err != nil { + return nil, nil, err + } + + sec, err := cfg.GetSection("") + if err != nil { + return nil, nil, err + } + + order := sec.KeyStrings() + + for _, k := range order { + v, err := sec.GetKey(k) + switch { + case v.String() == "": + return nil, nil, fmt.Errorf("no value for key '%s'", k) + case err != nil: + return nil, nil, err + } + } + + return sec.KeysHash(), order, nil +} diff --git a/worker/lib/foldermap_test.go b/worker/lib/foldermap_test.go new file mode 100644 index 0000000..ec8b6d5 --- /dev/null +++ b/worker/lib/foldermap_test.go @@ -0,0 +1,54 @@ +package lib_test + +import ( + "reflect" + "strings" + "testing" + + "git.sr.ht/~rjarry/aerc/worker/lib" +) + +func TestFolderMap(t *testing.T) { + text := `#this is comment + + Sent = [Gmail]/Sent + + # a comment between entries + Spam=[Gmail]/Spam # this is comment after the values + ` + fmap, order, err := lib.ParseFolderMap(strings.NewReader(text)) + if err != nil { + t.Errorf("parsing failed: %v", err) + } + + want_map := map[string]string{ + "Sent": "[Gmail]/Sent", + "Spam": "[Gmail]/Spam", + } + want_order := []string{"Sent", "Spam"} + + if !reflect.DeepEqual(order, want_order) { + t.Errorf("order is not correct; want: %v, got: %v", + want_order, order) + } + + if !reflect.DeepEqual(fmap, want_map) { + t.Errorf("map is not correct; want: %v, got: %v", + want_map, fmap) + } +} + +func TestFolderMap_ExpectFails(t *testing.T) { + tests := []string{ + `key = `, + ` = value`, + ` = `, + `key = #value`, + } + for _, text := range tests { + _, _, err := lib.ParseFolderMap(strings.NewReader(text)) + if err == nil { + t.Errorf("expected to fail, but it did not: %v", text) + } + } +} diff --git a/worker/lib/headers.go b/worker/lib/headers.go new file mode 100644 index 0000000..391d1d2 --- /dev/null +++ b/worker/lib/headers.go @@ -0,0 +1,29 @@ +package lib + +import ( + "strings" + + "github.com/emersion/go-message/mail" +) + +// LimitHeaders returns a new Header with the specified headers included or +// excluded +func LimitHeaders(hdr *mail.Header, fields []string, exclude bool) *mail.Header { + fieldMap := make(map[string]struct{}, len(fields)) + for _, f := range fields { + fieldMap[strings.ToLower(f)] = struct{}{} + } + nh := &mail.Header{} + curFields := hdr.Fields() + for curFields.Next() { + key := strings.ToLower(curFields.Key()) + _, present := fieldMap[key] + // XOR exclude and present. When they are equal, it means we + // should not add the header to the new header struct + if exclude == present { + continue + } + nh.Add(key, curFields.Value()) + } + return nh +} diff --git a/worker/lib/maildir.go b/worker/lib/maildir.go new file mode 100644 index 0000000..aada524 --- /dev/null +++ b/worker/lib/maildir.go @@ -0,0 +1,152 @@ +package lib + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "git.sr.ht/~rjarry/aerc/models" + "github.com/emersion/go-maildir" +) + +type MaildirStore struct { + root string + maildirpp bool // whether to use Maildir++ directory layout +} + +func NewMaildirStore(root string, maildirpp bool) (*MaildirStore, error) { + f, err := os.Open(root) + if err != nil { + return nil, err + } + defer f.Close() + s, err := f.Stat() + if err != nil { + return nil, err + } + if !s.IsDir() { + return nil, fmt.Errorf("Given maildir '%s' not a directory", root) + } + return &MaildirStore{ + root: root, maildirpp: maildirpp, + }, nil +} + +func (s *MaildirStore) FolderMap() (map[string]maildir.Dir, error) { + folders := make(map[string]maildir.Dir) + if s.maildirpp { + // In Maildir++ layout, INBOX is the root folder + folders["INBOX"] = maildir.Dir(s.root) + } + err := filepath.Walk(s.root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return fmt.Errorf("Invalid path '%s': error: %w", path, err) + } + if !info.IsDir() { + return nil + } + + // Skip maildir's default directories + n := info.Name() + if n == "new" || n == "tmp" || n == "cur" { + return filepath.SkipDir + } + + // Get the relative path from the parent directory + dirPath, err := filepath.Rel(s.root, path) + if err != nil { + return err + } + + // Skip the parent directory + if dirPath == "." { + return nil + } + + // Drop dirs that lack {new,tmp,cur} subdirs + for _, sub := range []string{"new", "tmp", "cur"} { + if _, err := os.Stat(filepath.Join(path, sub)); os.IsNotExist(err) { + return nil + } + } + + if s.maildirpp { + // In Maildir++ layout, mailboxes are stored in a single directory + // and prefixed with a dot, and subfolders are separated by dots. + if !strings.HasPrefix(dirPath, ".") { + return filepath.SkipDir + } + dirPath = strings.TrimPrefix(dirPath, ".") + dirPath = strings.ReplaceAll(dirPath, ".", "/") + folders[dirPath] = maildir.Dir(path) + + // Since all mailboxes are stored in a single directory, don't + // recurse into subdirectories + return filepath.SkipDir + } + + folders[dirPath] = maildir.Dir(path) + return nil + }) + return folders, err +} + +// Folder returns a maildir.Dir with the specified name inside the Store +func (s *MaildirStore) Dir(name string) maildir.Dir { + if s.maildirpp { + // Use Maildir++ layout + if name == "INBOX" { + return maildir.Dir(s.root) + } + return maildir.Dir(filepath.Join(s.root, "."+strings.ReplaceAll(name, "/", "."))) + } + return maildir.Dir(filepath.Join(s.root, name)) +} + +// uidReg matches filename encoded UIDs in maildirs synched with mbsync or +// OfflineIMAP +var uidReg = regexp.MustCompile(`,U=\d+`) + +func StripUIDFromMessageFilename(basename string) string { + return uidReg.ReplaceAllString(basename, "") +} + +var MaildirToFlag = map[maildir.Flag]models.Flags{ + maildir.FlagReplied: models.AnsweredFlag, + maildir.FlagSeen: models.SeenFlag, + maildir.FlagTrashed: models.DeletedFlag, + maildir.FlagFlagged: models.FlaggedFlag, + maildir.FlagDraft: models.DraftFlag, + maildir.FlagPassed: models.ForwardedFlag, +} + +var FlagToMaildir = map[models.Flags]maildir.Flag{ + models.AnsweredFlag: maildir.FlagReplied, + models.SeenFlag: maildir.FlagSeen, + models.DeletedFlag: maildir.FlagTrashed, + models.FlaggedFlag: maildir.FlagFlagged, + models.DraftFlag: maildir.FlagDraft, + models.ForwardedFlag: maildir.FlagPassed, +} + +func FromMaildirFlags(maildirFlags []maildir.Flag) models.Flags { + var flags models.Flags + for _, maildirFlag := range maildirFlags { + if flag, ok := MaildirToFlag[maildirFlag]; ok { + flags |= flag + } + } + return flags +} + +func ToMaildirFlags(flags models.Flags) []maildir.Flag { + var maildirFlags []maildir.Flag + for flag, maildirFlag := range FlagToMaildir { + if flags.Has(flag) { + maildirFlags = append(maildirFlags, maildirFlag) + } + } + return maildirFlags +} diff --git a/worker/lib/search.go b/worker/lib/search.go new file mode 100644 index 0000000..9715c4a --- /dev/null +++ b/worker/lib/search.go @@ -0,0 +1,201 @@ +package lib + +import ( + "io" + "strings" + "unicode" + + "git.sr.ht/~rjarry/aerc/lib" + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/rfc822" + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/types" + "git.sr.ht/~rjarry/go-opt/v2" +) + +func Search(messages []rfc822.RawMessage, criteria *types.SearchCriteria) ([]models.UID, error) { + criteria.PrepareHeader() + requiredParts := GetRequiredParts(criteria) + + var matchedUids []models.UID + for _, m := range messages { + success, err := SearchMessage(m, criteria, requiredParts) + if err != nil { + return nil, err + } else if success { + matchedUids = append(matchedUids, m.UID()) + } + } + + return matchedUids, nil +} + +// searchMessage executes the search criteria for the given RawMessage, +// returns true if search succeeded +func SearchMessage(message rfc822.RawMessage, criteria *types.SearchCriteria, + parts MsgParts, +) (bool, error) { + if criteria == nil { + return true, nil + } + // setup parts of the message to use in the search + // this is so that we try to minimise reading unnecessary parts + var ( + flags models.Flags + info *models.MessageInfo + text string + err error + ) + + if parts&FLAGS > 0 { + flags, err = message.ModelFlags() + if err != nil { + return false, err + } + } + info, err = rfc822.MessageInfo(message) + if err != nil { + return false, err + } + switch { + case parts&BODY > 0: + path := lib.FindFirstNonMultipart(info.BodyStructure, nil) + reader, err := message.NewReader() + if err != nil { + return false, err + } + defer reader.Close() + msg, err := rfc822.ReadMessage(reader) + if err != nil { + return false, err + } + part, err := rfc822.FetchEntityPartReader(msg, path) + if err != nil { + return false, err + } + bytes, err := io.ReadAll(part) + if err != nil { + return false, err + } + text = string(bytes) + case parts&ALL > 0: + reader, err := message.NewReader() + if err != nil { + return false, err + } + defer reader.Close() + bytes, err := io.ReadAll(reader) + if err != nil { + return false, err + } + text = string(bytes) + default: + text = info.Envelope.Subject + } + + // now search through the criteria + // implicit AND at the moment so fail fast + if criteria.Headers != nil { + for k, v := range criteria.Headers { + headerValue := info.RFC822Headers.Get(k) + for _, text := range v { + if !containsSmartCase(headerValue, text) { + return false, nil + } + } + } + } + + args := opt.LexArgs(strings.Join(criteria.Terms, " ")) + for _, searchTerm := range args.Args() { + if !containsSmartCase(text, searchTerm) { + return false, nil + } + } + if criteria.WithFlags != 0 { + if !flags.Has(criteria.WithFlags) { + return false, nil + } + } + if criteria.WithoutFlags != 0 { + if flags.Has(criteria.WithoutFlags) { + return false, nil + } + } + if parts&DATE > 0 { + if date, err := info.RFC822Headers.Date(); err != nil { + log.Errorf("Failed to get date from header: %v", err) + } else { + if !criteria.StartDate.IsZero() { + if date.Before(criteria.StartDate) { + return false, nil + } + } + if !criteria.EndDate.IsZero() { + if date.After(criteria.EndDate) { + return false, nil + } + } + } + } + return true, nil +} + +// containsSmartCase is a smarter version of strings.Contains for searching. +// Is case-insensitive unless substr contains an upper case character +func containsSmartCase(s string, substr string) bool { + if hasUpper(substr) { + return strings.Contains(s, substr) + } + return strings.Contains(strings.ToLower(s), strings.ToLower(substr)) +} + +func hasUpper(s string) bool { + for _, r := range s { + if unicode.IsUpper(r) { + return true + } + } + return false +} + +// The parts of a message, kind of +type MsgParts int + +const NONE MsgParts = 0 +const ( + FLAGS MsgParts = 1 << iota + HEADER + DATE + BODY + ALL +) + +// Returns a bitmask of the parts of the message required to be loaded for the +// given criteria +func GetRequiredParts(criteria *types.SearchCriteria) MsgParts { + required := NONE + if criteria == nil { + return required + } + if len(criteria.Headers) > 0 { + required |= HEADER + } + if !criteria.StartDate.IsZero() || !criteria.EndDate.IsZero() { + required |= DATE + } + if criteria.SearchBody { + required |= BODY + } + if criteria.SearchAll { + required |= ALL + } + if criteria.WithFlags != 0 { + required |= FLAGS + } + if criteria.WithoutFlags != 0 { + required |= FLAGS + } + + return required +} diff --git a/worker/lib/size.go b/worker/lib/size.go new file mode 100644 index 0000000..f00437c --- /dev/null +++ b/worker/lib/size.go @@ -0,0 +1,15 @@ +package lib + +import ( + "fmt" + "os" +) + +// FileSize returns the size of the file specified by name +func FileSize(name string) (uint32, error) { + fileInfo, err := os.Stat(name) + if err != nil { + return 0, fmt.Errorf("failed to obtain fileinfo: %w", err) + } + return uint32(fileInfo.Size()), nil +} diff --git a/worker/lib/sort.go b/worker/lib/sort.go new file mode 100644 index 0000000..70a64c7 --- /dev/null +++ b/worker/lib/sort.go @@ -0,0 +1,149 @@ +package lib + +import ( + "sort" + "strings" + + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/types" + "github.com/emersion/go-message/mail" +) + +func Sort(messageInfos []*models.MessageInfo, + criteria []*types.SortCriterion, +) ([]models.UID, error) { + // loop through in reverse to ensure we sort by non-primary fields first + for i := len(criteria) - 1; i >= 0; i-- { + criterion := criteria[i] + switch criterion.Field { + case types.SortArrival: + sortSlice(criterion, messageInfos, func(i, j int) bool { + return messageInfos[i].InternalDate.Before(messageInfos[j].InternalDate) + }) + case types.SortCc: + sortAddresses(messageInfos, criterion, + func(msgInfo *models.MessageInfo) []*mail.Address { + return msgInfo.Envelope.Cc + }) + case types.SortDate: + sortSlice(criterion, messageInfos, func(i, j int) bool { + return messageInfos[i].Envelope.Date.Before(messageInfos[j].Envelope.Date) + }) + case types.SortFrom: + sortAddresses(messageInfos, criterion, + func(msgInfo *models.MessageInfo) []*mail.Address { + return msgInfo.Envelope.From + }) + case types.SortRead: + sortFlags(messageInfos, criterion, models.SeenFlag) + case types.SortFlagged: + sortFlags(messageInfos, criterion, models.FlaggedFlag) + case types.SortSize: + sortSlice(criterion, messageInfos, func(i, j int) bool { + return messageInfos[i].Size < messageInfos[j].Size + }) + case types.SortSubject: + sortStrings(messageInfos, criterion, + func(msgInfo *models.MessageInfo) string { + subject := strings.ToLower(msgInfo.Envelope.Subject) + subject = strings.TrimPrefix(subject, "re: ") + return strings.TrimPrefix(subject, "fwd: ") + }) + case types.SortTo: + sortAddresses(messageInfos, criterion, + func(msgInfo *models.MessageInfo) []*mail.Address { + return msgInfo.Envelope.To + }) + } + } + var uids []models.UID + // copy in reverse as msgList displays backwards + for i := len(messageInfos) - 1; i >= 0; i-- { + uids = append(uids, messageInfos[i].Uid) + } + return uids, nil +} + +func sortAddresses(messageInfos []*models.MessageInfo, criterion *types.SortCriterion, + getValue func(*models.MessageInfo) []*mail.Address, +) { + sortSlice(criterion, messageInfos, func(i, j int) bool { + addressI, addressJ := getValue(messageInfos[i]), getValue(messageInfos[j]) + var firstI, firstJ *mail.Address + if len(addressI) > 0 { + firstI = addressI[0] + } + if len(addressJ) > 0 { + firstJ = addressJ[0] + } + if firstI != nil && firstJ != nil { + getName := func(addr *mail.Address) string { + if addr.Name != "" { + return addr.Name + } else { + return addr.Address + } + } + return getName(firstI) < getName(firstJ) + } else { + return firstI != nil && firstJ == nil + } + }) +} + +func sortFlags(messageInfos []*models.MessageInfo, criterion *types.SortCriterion, + testFlag models.Flags, +) { + var slice []*boolStore + for _, msgInfo := range messageInfos { + slice = append(slice, &boolStore{ + Value: msgInfo.Flags.Has(testFlag), + MsgInfo: msgInfo, + }) + } + sortSlice(criterion, slice, func(i, j int) bool { + valI, valJ := slice[i].Value, slice[j].Value + return valI && !valJ + }) + for i := 0; i < len(messageInfos); i++ { + messageInfos[i] = slice[i].MsgInfo + } +} + +func sortStrings(messageInfos []*models.MessageInfo, criterion *types.SortCriterion, + getValue func(*models.MessageInfo) string, +) { + var slice []*lexiStore + for _, msgInfo := range messageInfos { + slice = append(slice, &lexiStore{ + Value: getValue(msgInfo), + MsgInfo: msgInfo, + }) + } + sortSlice(criterion, slice, func(i, j int) bool { + return slice[i].Value < slice[j].Value + }) + for i := 0; i < len(messageInfos); i++ { + messageInfos[i] = slice[i].MsgInfo + } +} + +type lexiStore struct { + Value string + MsgInfo *models.MessageInfo +} + +type boolStore struct { + Value bool + MsgInfo *models.MessageInfo +} + +func sortSlice(criterion *types.SortCriterion, slice interface{}, less func(i, j int) bool) { + if criterion.Reverse { + sort.SliceStable(slice, func(i, j int) bool { + return less(j, i) + }) + } else { + sort.SliceStable(slice, less) + } +} diff --git a/worker/maildir/container.go b/worker/maildir/container.go new file mode 100644 index 0000000..c23825d --- /dev/null +++ b/worker/maildir/container.go @@ -0,0 +1,149 @@ +package maildir + +import ( + "fmt" + "os" + "path/filepath" + "sort" + + "github.com/emersion/go-maildir" + + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/lib" +) + +// A Container is a directory which contains other directories which adhere to +// the Maildir spec +type Container struct { + Store *lib.MaildirStore + recentUIDS map[models.UID]struct{} // used to set the recent flag +} + +// NewContainer creates a new container at the specified directory +func NewContainer(dir string, maildirpp bool) (*Container, error) { + store, err := lib.NewMaildirStore(dir, maildirpp) + if err != nil { + return nil, err + } + return &Container{ + Store: store, + recentUIDS: make(map[models.UID]struct{}), + }, nil +} + +// SyncNewMail adds emails from new to cur, tracking them +func (c *Container) SyncNewMail(dir maildir.Dir) error { + keys, err := dir.Unseen() + if err != nil { + return err + } + for _, key := range keys { + c.recentUIDS[models.UID(key)] = struct{}{} + } + return nil +} + +// OpenDirectory opens an existing maildir in the container by name, moves new +// messages into cur, and registers the new keys in the UIDStore. +func (c *Container) OpenDirectory(name string) (maildir.Dir, error) { + dir := c.Store.Dir(name) + if err := c.SyncNewMail(dir); err != nil { + return dir, err + } + return dir, nil +} + +// IsRecent returns if a uid has the Recent flag set +func (c *Container) IsRecent(uid models.UID) bool { + _, ok := c.recentUIDS[uid] + return ok +} + +// ClearRecentFlag removes the Recent flag from the message with the given uid +func (c *Container) ClearRecentFlag(uid models.UID) { + delete(c.recentUIDS, uid) +} + +// UIDs fetches the unique message identifiers for the maildir +func (c *Container) UIDs(d maildir.Dir) ([]models.UID, error) { + keys, err := d.Keys() + if err != nil && len(keys) == 0 { + return nil, fmt.Errorf("could not get keys for %s: %w", d, err) + } + if err != nil { + log.Errorf("could not get all keys for %s: %s", d, err.Error()) + } + sort.Strings(keys) + var uids []models.UID + for _, key := range keys { + uids = append(uids, models.UID(key)) + } + return uids, err +} + +// Message returns a Message struct for the given UID and maildir +func (c *Container) Message(d maildir.Dir, uid models.UID) (*Message, error) { + return &Message{ + dir: d, + uid: uid, + key: string(uid), + }, nil +} + +// DeleteAll deletes a set of messages by UID and returns the subset of UIDs +// which were successfully deleted, stopping upon the first error. +func (c *Container) DeleteAll(d maildir.Dir, uids []models.UID) ([]models.UID, error) { + var success []models.UID + for _, uid := range uids { + msg, err := c.Message(d, uid) + if err != nil { + return success, err + } + if err := msg.Remove(); err != nil { + return success, err + } + success = append(success, uid) + } + return success, nil +} + +func (c *Container) CopyAll( + dest maildir.Dir, src maildir.Dir, uids []models.UID, +) error { + for _, uid := range uids { + if err := c.copyMessage(dest, src, uid); err != nil { + return fmt.Errorf("could not copy message %s: %w", uid, err) + } + } + return nil +} + +func (c *Container) copyMessage( + dest maildir.Dir, src maildir.Dir, uid models.UID, +) error { + _, err := src.Copy(dest, string(uid)) + return err +} + +func (c *Container) MoveAll(dest maildir.Dir, src maildir.Dir, uids []models.UID) ([]models.UID, error) { + var success []models.UID + for _, uid := range uids { + if err := c.moveMessage(dest, src, uid); err != nil { + return success, fmt.Errorf("could not move message %s: %w", uid, err) + } + success = append(success, uid) + } + return success, nil +} + +func (c *Container) moveMessage(dest maildir.Dir, src maildir.Dir, uid models.UID) error { + path, err := src.Filename(string(uid)) + if err != nil { + return fmt.Errorf("could not find path for message id %s: %w", uid, err) + } + // Remove encoded UID information from the key to prevent sync issues + name := lib.StripUIDFromMessageFilename(filepath.Base(path)) + destPath := filepath.Join(string(dest), "cur", name) + return os.Rename(path, destPath) +} diff --git a/worker/maildir/message.go b/worker/maildir/message.go new file mode 100644 index 0000000..14c8372 --- /dev/null +++ b/worker/maildir/message.go @@ -0,0 +1,144 @@ +package maildir + +import ( + "fmt" + "io" + + "github.com/emersion/go-maildir" + + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/rfc822" + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/lib" +) + +// A Message is an individual email inside of a maildir.Dir. +type Message struct { + dir maildir.Dir + uid models.UID + key string +} + +// NewReader reads a message into memory and returns an io.Reader for it. +func (m Message) NewReader() (io.ReadCloser, error) { + return m.dir.Open(m.key) +} + +// Flags fetches the set of flags currently applied to the message. +func (m Message) Flags() ([]maildir.Flag, error) { + return m.dir.Flags(m.key) +} + +// ModelFlags fetches the set of models.flags currently applied to the message. +func (m Message) ModelFlags() (models.Flags, error) { + flags, err := m.dir.Flags(m.key) + if err != nil { + return 0, err + } + return lib.FromMaildirFlags(flags), nil +} + +// SetFlags replaces the message's flags with a new set. +func (m Message) SetFlags(flags []maildir.Flag) error { + return m.dir.SetFlags(m.key, flags) +} + +// SetOneFlag enables or disables a single message flag on the message. +func (m Message) SetOneFlag(flag maildir.Flag, enable bool) error { + flags, err := m.Flags() + if err != nil { + return fmt.Errorf("could not read previous flags: %w", err) + } + if enable { + flags = append(flags, flag) + return m.SetFlags(flags) + } + var newFlags []maildir.Flag + for _, oldFlag := range flags { + if oldFlag != flag { + newFlags = append(newFlags, oldFlag) + } + } + return m.SetFlags(newFlags) +} + +// MarkForwarded either adds or removes the maildir.FlagForwarded flag +// from the message. +func (m Message) MarkForwarded(forwarded bool) error { + return m.SetOneFlag(maildir.FlagPassed, forwarded) +} + +// MarkReplied either adds or removes the maildir.FlagReplied flag from the +// message. +func (m Message) MarkReplied(answered bool) error { + return m.SetOneFlag(maildir.FlagReplied, answered) +} + +// Remove deletes the email immediately. +func (m Message) Remove() error { + return m.dir.Remove(m.key) +} + +// MessageInfo populates a models.MessageInfo struct for the message. +func (m Message) MessageInfo() (*models.MessageInfo, error) { + info, err := rfc822.MessageInfo(m) + if err != nil { + return nil, err + } + info.Size, err = m.Size() + if err != nil { + // don't care if size retrieval fails + log.Debugf("message size: %v", err) + } + return info, nil +} + +func (m Message) Size() (uint32, error) { + name, err := m.dir.Filename(m.key) + if err != nil { + return 0, fmt.Errorf("failed to get filename: %w", err) + } + size, err := lib.FileSize(name) + if err != nil { + return 0, fmt.Errorf("failed to get filesize: %w", err) + } + return size, nil +} + +// MessageHeaders populates a models.MessageInfo struct for the message with +// minimal information, used for sorting and threading. +func (m Message) MessageHeaders() (*models.MessageInfo, error) { + info, err := rfc822.MessageHeaders(m) + if err != nil { + return nil, err + } + info.Size, err = m.Size() + if err != nil { + // don't care if size retrieval fails + log.Debugf("message size failed: %v", err) + } + return info, nil +} + +// NewBodyPartReader creates a new io.Reader for the requested body part(s) of +// the message. +func (m Message) NewBodyPartReader(requestedParts []int) (io.Reader, error) { + f, err := m.dir.Open(m.key) + if err != nil { + return nil, err + } + defer f.Close() + msg, err := rfc822.ReadMessage(f) + if err != nil { + return nil, fmt.Errorf("could not read message: %w", err) + } + return rfc822.FetchEntityPartReader(msg, requestedParts) +} + +func (m Message) UID() models.UID { + return m.uid +} + +func (m Message) Labels() ([]string, error) { + return nil, nil +} diff --git a/worker/maildir/search.go b/worker/maildir/search.go new file mode 100644 index 0000000..de2d053 --- /dev/null +++ b/worker/maildir/search.go @@ -0,0 +1,67 @@ +package maildir + +import ( + "context" + "runtime" + "sync" + + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/lib" + "git.sr.ht/~rjarry/aerc/worker/types" +) + +func (w *Worker) search(ctx context.Context, criteria *types.SearchCriteria) ([]models.UID, error) { + criteria.PrepareHeader() + requiredParts := lib.GetRequiredParts(criteria) + w.worker.Debugf("Required parts bitmask for search: %b", requiredParts) + + keys, err := w.c.UIDs(*w.selected) + if err != nil { + return nil, err + } + + var matchedUids []models.UID + mu := sync.Mutex{} + wg := sync.WaitGroup{} + // Hard limit at 2x CPU cores + max := runtime.NumCPU() * 2 + limit := make(chan struct{}, max) + for _, key := range keys { + select { + case <-ctx.Done(): + return nil, context.Canceled + default: + limit <- struct{}{} + wg.Add(1) + go func(key models.UID) { + defer log.PanicHandler() + defer wg.Done() + success, err := w.searchKey(key, criteria, requiredParts) + if err != nil { + // don't return early so that we can still get some results + w.worker.Errorf("Failed to search key %d: %v", key, err) + } else if success { + mu.Lock() + matchedUids = append(matchedUids, key) + mu.Unlock() + } + <-limit + }(key) + + } + } + wg.Wait() + return matchedUids, nil +} + +// Execute the search criteria for the given key, returns true if search succeeded +func (w *Worker) searchKey(key models.UID, criteria *types.SearchCriteria, + parts lib.MsgParts, +) (bool, error) { + message, err := w.c.Message(*w.selected, key) + if err != nil { + return false, err + } + return lib.SearchMessage(message, criteria, parts) +} diff --git a/worker/maildir/worker.go b/worker/maildir/worker.go new file mode 100644 index 0000000..74efdc3 --- /dev/null +++ b/worker/maildir/worker.go @@ -0,0 +1,984 @@ +package maildir + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "io" + "net/url" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strings" + "sync" + "time" + + "github.com/emersion/go-maildir" + + aercLib "git.sr.ht/~rjarry/aerc/lib" + "git.sr.ht/~rjarry/aerc/lib/iterator" + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/watchers" + "git.sr.ht/~rjarry/aerc/lib/xdg" + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/handlers" + "git.sr.ht/~rjarry/aerc/worker/lib" + "git.sr.ht/~rjarry/aerc/worker/middleware" + "git.sr.ht/~rjarry/aerc/worker/types" +) + +func init() { + handlers.RegisterWorkerFactory("maildir", NewWorker) + handlers.RegisterWorkerFactory("maildirpp", NewMaildirppWorker) +} + +var errUnsupported = fmt.Errorf("unsupported command") + +// A Worker handles interfacing between aerc's UI and a group of maildirs. +type Worker struct { + c *Container + selected *maildir.Dir + selectedName string + selectedInfo *models.DirectoryInfo + worker types.WorkerInteractor + watcher watchers.FSWatcher + watcherDebounce *time.Timer + fsEvents chan struct{} + currentSortCriteria []*types.SortCriterion + maildirpp bool // whether to use Maildir++ directory layout + capabilities *models.Capabilities + headers []string + headersExclude []string +} + +// NewWorker creates a new maildir worker with the provided worker. +func NewWorker(worker *types.Worker) (types.Backend, error) { + watch, err := watchers.NewWatcher() + if err != nil { + return nil, fmt.Errorf("could not create file system watcher: %w", err) + } + return &Worker{ + capabilities: &models.Capabilities{ + Sort: true, + Thread: true, + }, + worker: worker, + watcher: watch, + fsEvents: make(chan struct{}), + }, nil +} + +// NewMaildirppWorker creates a new Maildir++ worker with the provided worker. +func NewMaildirppWorker(worker *types.Worker) (types.Backend, error) { + watch, err := watchers.NewWatcher() + if err != nil { + return nil, fmt.Errorf("could not create file system watcher: %w", err) + } + return &Worker{ + capabilities: &models.Capabilities{ + Sort: true, + Thread: true, + }, + worker: worker, + watcher: watch, + maildirpp: true, + }, nil +} + +// Run starts the worker's message handling loop. +func (w *Worker) Run() { + for { + select { + case action := <-w.worker.Actions(): + w.handleAction(action) + case <-w.watcher.Events(): + if w.watcherDebounce != nil { + w.watcherDebounce.Stop() + } + // Debounce FS changes + w.watcherDebounce = time.AfterFunc(50*time.Millisecond, func() { + defer log.PanicHandler() + w.fsEvents <- struct{}{} + }) + case <-w.fsEvents: + w.handleFSEvent() + } + } +} + +func (w *Worker) Capabilities() *models.Capabilities { + return w.capabilities +} + +func (w *Worker) PathSeparator() string { + return string(os.PathSeparator) +} + +func (w *Worker) handleAction(action types.WorkerMessage) { + msg := w.worker.ProcessAction(action) + switch msg := msg.(type) { + // Explicitly handle all asynchronous actions. Async actions are + // responsible for posting their own Done message + case *types.CheckMail: + go w.handleCheckMail(msg) + default: + // Default handling, will be performed synchronously + err := w.handleMessage(msg) + switch { + case errors.Is(err, errUnsupported): + w.worker.PostMessage(&types.Unsupported{ + Message: types.RespondTo(msg), + }, nil) + case errors.Is(err, context.Canceled): + w.worker.PostMessage(&types.Cancelled{ + Message: types.RespondTo(msg), + }, nil) + case err != nil: + w.worker.PostMessage(&types.Error{ + Message: types.RespondTo(msg), + Error: err, + }, nil) + default: + w.done(msg) + } + } +} + +func (w *Worker) handleFSEvent() { + // if there's not a selected directory to rescan, ignore + if w.selected == nil { + return + } + err := w.c.SyncNewMail(*w.selected) + if err != nil { + w.worker.Errorf("could not move new to cur : %v", err) + return + } + + w.selectedInfo = w.getDirectoryInfo(w.selectedName) + w.worker.PostMessage(&types.DirectoryInfo{ + Info: w.selectedInfo, + Refetch: true, + }, nil) +} + +func (w *Worker) done(msg types.WorkerMessage) { + w.worker.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil) +} + +func (w *Worker) err(msg types.WorkerMessage, err error) { + w.worker.PostMessage(&types.Error{ + Message: types.RespondTo(msg), + Error: err, + }, nil) +} + +func splitMaildirFile(name string) (uniq string, flags []maildir.Flag, err error) { + i := strings.LastIndexByte(name, ':') + if i < 0 { + return "", nil, &maildir.MailfileError{Name: name} + } + info := name[i+1:] + uniq = name[:i] + if len(info) < 2 { + return "", nil, &maildir.FlagError{Info: info, Experimental: false} + } + if info[1] != ',' || info[0] != '2' { + return "", nil, &maildir.FlagError{Info: info, Experimental: false} + } + if info[0] == '1' { + return "", nil, &maildir.FlagError{Info: info, Experimental: true} + } + flags = []maildir.Flag(info[2:]) + sort.Slice(flags, func(i, j int) bool { return info[i] < info[j] }) + return uniq, flags, nil +} + +func dirFiles(name string) ([]string, error) { + dir, err := os.Open(filepath.Join(name, "cur")) + if err != nil { + return nil, err + } + defer dir.Close() + return dir.Readdirnames(-1) +} + +func (w *Worker) getDirectoryInfo(name string) *models.DirectoryInfo { + dirInfo := &models.DirectoryInfo{ + Name: name, + // total messages + Exists: 0, + // new messages since mailbox was last opened + Recent: 0, + // total unread + Unseen: 0, + } + + dir := w.c.Store.Dir(name) + var keyFlags map[string][]maildir.Flag + files, err := dirFiles(string(dir)) + if err == nil { + keyFlags = make(map[string][]maildir.Flag, len(files)) + for _, v := range files { + key, flags, err := splitMaildirFile(v) + if err != nil { + w.worker.Errorf("%q: error parsing flags (%q): %v", v, key, err) + continue + } + keyFlags[key] = flags + } + } else { + w.worker.Tracef("disabled flags cache: %q: %v", dir, err) + } + + uids, err := w.c.UIDs(dir) + if err != nil && len(uids) == 0 { + w.worker.Errorf("could not get uids: %v", err) + return dirInfo + } + + dirInfo.Exists = len(uids) + for _, uid := range uids { + message, err := w.c.Message(dir, uid) + if err != nil { + w.worker.Errorf("could not get message: %v", err) + continue + } + var flags []maildir.Flag + if keyFlags != nil { + ok := false + flags, ok = keyFlags[message.key] + if !ok { + w.worker.Tracef("message (key=%q uid=%d) not found in map cache", + message.key, message.uid) + flags, err = message.Flags() + if err != nil { + w.worker.Errorf("could not get flags: %v", err) + continue + } + } + } else { + flags, err = message.Flags() + if err != nil { + w.worker.Errorf("could not get flags: %v", err) + continue + } + } + seen := false + for _, flag := range flags { + if flag == maildir.FlagSeen { + seen = true + break + } + } + if !seen { + dirInfo.Unseen++ + } + if w.c.IsRecent(uid) { + dirInfo.Recent++ + } + } + return dirInfo +} + +func (w *Worker) handleMessage(msg types.WorkerMessage) error { + switch msg := msg.(type) { + case *types.Unsupported: + // No-op + case *types.Configure: + return w.handleConfigure(msg) + case *types.Connect: + return w.handleConnect(msg) + case *types.ListDirectories: + return w.handleListDirectories(msg) + case *types.OpenDirectory: + return w.handleOpenDirectory(msg) + case *types.FetchDirectoryContents: + return w.handleFetchDirectoryContents(msg) + case *types.FetchDirectoryThreaded: + return w.handleFetchDirectoryThreaded(msg) + case *types.CreateDirectory: + return w.handleCreateDirectory(msg) + case *types.RemoveDirectory: + return w.handleRemoveDirectory(msg) + case *types.FetchMessageHeaders: + return w.handleFetchMessageHeaders(msg) + case *types.FetchMessageBodyPart: + return w.handleFetchMessageBodyPart(msg) + case *types.FetchFullMessages: + return w.handleFetchFullMessages(msg) + case *types.DeleteMessages: + return w.handleDeleteMessages(msg) + case *types.FlagMessages: + return w.handleFlagMessages(msg) + case *types.AnsweredMessages: + return w.handleAnsweredMessages(msg) + case *types.ForwardedMessages: + return w.handleForwardedMessages(msg) + case *types.CopyMessages: + return w.handleCopyMessages(msg) + case *types.MoveMessages: + return w.handleMoveMessages(msg) + case *types.AppendMessage: + return w.handleAppendMessage(msg) + case *types.SearchDirectory: + return w.handleSearchDirectory(msg) + } + return errUnsupported +} + +func (w *Worker) handleConfigure(msg *types.Configure) error { + u, err := url.Parse(msg.Config.Source) + if err != nil { + w.worker.Errorf("error configuring maildir worker: %v", err) + return err + } + dir := u.Path + if u.Host == "~" { + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("could not resolve home directory: %w", err) + } + dir = filepath.Join(home, u.Path) + } + if len(dir) == 0 { + return fmt.Errorf("could not resolve maildir from URL '%s'", msg.Config.Source) + } + c, err := NewContainer(dir, w.maildirpp) + if err != nil { + w.worker.Errorf("could not configure maildir: %s", dir) + return err + } + w.c = c + err = w.watcher.Configure(dir) + if err != nil { + return err + } + w.headers = msg.Config.Headers + w.headersExclude = msg.Config.HeadersExclude + w.worker.Debugf("configured base maildir: %s", dir) + + if name, ok := msg.Config.Params["folder-map"]; ok { + file := xdg.ExpandHome(name) + f, err := os.Open(file) + if err != nil { + return err + } + defer f.Close() + fmap, order, err := lib.ParseFolderMap(bufio.NewReader(f)) + if err != nil { + return err + } + w.worker = middleware.NewFolderMapper(w.worker, fmap, order) + } + + return nil +} + +func (w *Worker) handleConnect(msg *types.Connect) error { + return nil +} + +func (w *Worker) handleListDirectories(msg *types.ListDirectories) error { + // TODO If handleConfigure has returned error, w.c is nil. + // It could be better if we skip directory listing completely + // when configure fails. + if w.c == nil { + return errors.New("Incorrect maildir directory") + } + dirs, err := w.c.Store.FolderMap() + if err != nil { + w.worker.Errorf("failed listing directories: %v", err) + return err + } + for name := range dirs { + w.worker.PostMessage(&types.Directory{ + Message: types.RespondTo(msg), + Dir: &models.Directory{ + Name: name, + }, + }, nil) + + w.worker.PostMessage(&types.DirectoryInfo{ + Info: w.getDirectoryInfo(name), + }, nil) + } + return nil +} + +func (w *Worker) handleOpenDirectory(msg *types.OpenDirectory) error { + w.worker.Debugf("opening %s", msg.Directory) + + // open the directory + dir, err := w.c.OpenDirectory(msg.Directory) + if err != nil { + return err + } + + // remove existing watch paths + if w.selected != nil { + prevDir := filepath.Join(string(*w.selected), "new") + if err := w.watcher.Remove(prevDir); err != nil { + return fmt.Errorf("could not unwatch previous directory: %w", err) + } + prevDir = filepath.Join(string(*w.selected), "cur") + if err := w.watcher.Remove(prevDir); err != nil { + return fmt.Errorf("could not unwatch previous directory: %w", err) + } + } + + w.selected = &dir + w.selectedName = msg.Directory + + // add watch paths + newDir := filepath.Join(string(*w.selected), "new") + if err := w.watcher.Add(newDir); err != nil { + return fmt.Errorf("could not add watch to directory: %w", err) + } + newDir = filepath.Join(string(*w.selected), "cur") + if err := w.watcher.Add(newDir); err != nil { + return fmt.Errorf("could not add watch to directory: %w", err) + } + + if err := dir.Clean(); err != nil { + return fmt.Errorf("could not clean directory: %w", err) + } + + info := &types.DirectoryInfo{ + Info: w.getDirectoryInfo(msg.Directory), + } + w.selectedInfo = info.Info + w.worker.PostMessage(info, nil) + return nil +} + +func (w *Worker) handleFetchDirectoryContents( + msg *types.FetchDirectoryContents, +) error { + var ( + uids []models.UID + err error + ) + if msg.Filter != nil { + uids, err = w.search(msg.Context, msg.Filter) + if err != nil { + return err + } + } else { + uids, err = w.c.UIDs(*w.selected) + if err != nil && len(uids) == 0 { + w.worker.Errorf("failed scanning uids: %v", err) + return err + } + + if err != nil { + w.worker.PostMessage(&types.Error{ + Error: fmt.Errorf("could not get all uids for %s: %w", *w.selected, err), + }, nil) + } + } + sortedUids, err := w.sort(msg.Context, uids, msg.SortCriteria) + if err != nil { + w.worker.Errorf("failed sorting directory: %v", err) + return err + } + w.currentSortCriteria = msg.SortCriteria + w.worker.PostMessage(&types.DirectoryContents{ + Message: types.RespondTo(msg), + Uids: sortedUids, + }, nil) + return nil +} + +func (w *Worker) sort(ctx context.Context, uids []models.UID, criteria []*types.SortCriterion) ([]models.UID, error) { + if len(criteria) == 0 { + // At least sort by uid, parallel searching can create random + // order + sort.Slice(uids, func(i int, j int) bool { + return uids[i] < uids[j] + }) + return uids, nil + } + var msgInfos []*models.MessageInfo + mu := sync.Mutex{} + wg := sync.WaitGroup{} + // Hard limit at 2x CPU cores + max := runtime.NumCPU() * 2 + limit := make(chan struct{}, max) + for _, uid := range uids { + select { + case <-ctx.Done(): + return nil, context.Canceled + default: + limit <- struct{}{} + wg.Add(1) + go func(uid models.UID) { + defer log.PanicHandler() + defer wg.Done() + info, err := w.msgHeadersFromUid(uid) + if err != nil { + w.worker.Errorf("could not get message info: %v", err) + <-limit + return + } + mu.Lock() + msgInfos = append(msgInfos, info) + mu.Unlock() + <-limit + }(uid) + } + } + + wg.Wait() + sortedUids, err := lib.Sort(msgInfos, criteria) + if err != nil { + w.worker.Errorf("could not sort the messages: %v", err) + return nil, err + } + return sortedUids, nil +} + +func (w *Worker) handleFetchDirectoryThreaded( + msg *types.FetchDirectoryThreaded, +) error { + var ( + uids []models.UID + err error + ) + if msg.Filter != nil { + uids, err = w.search(msg.Context, msg.Filter) + if err != nil { + return err + } + } else { + uids, err = w.c.UIDs(*w.selected) + if err != nil && len(uids) == 0 { + w.worker.Errorf("failed scanning uids: %v", err) + return err + } + } + threads, err := w.threads(msg.Context, uids, msg.SortCriteria) + if err != nil { + w.worker.Errorf("failed sorting directory: %v", err) + return err + } + w.currentSortCriteria = msg.SortCriteria + w.worker.PostMessage(&types.DirectoryThreaded{ + Message: types.RespondTo(msg), + Threads: threads, + }, nil) + return nil +} + +func (w *Worker) threads(ctx context.Context, uids []models.UID, + criteria []*types.SortCriterion, +) ([]*types.Thread, error) { + builder := aercLib.NewThreadBuilder(iterator.NewFactory(false), false) + msgInfos := make([]*models.MessageInfo, 0, len(uids)) + mu := sync.Mutex{} + wg := sync.WaitGroup{} + max := runtime.NumCPU() * 2 + limit := make(chan struct{}, max) + for _, uid := range uids { + select { + case <-ctx.Done(): + return nil, context.Canceled + default: + limit <- struct{}{} + wg.Add(1) + go func(uid models.UID) { + defer log.PanicHandler() + defer wg.Done() + info, err := w.msgHeadersFromUid(uid) + if err != nil { + w.worker.Errorf("could not get message info: %v", err) + <-limit + return + } + mu.Lock() + builder.Update(info) + msgInfos = append(msgInfos, info) + mu.Unlock() + <-limit + }(uid) + } + } + wg.Wait() + var err error + switch { + case len(criteria) == 0: + sort.Slice(uids, func(i int, j int) bool { + return uids[i] < uids[j] + }) + default: + uids, err = lib.Sort(msgInfos, criteria) + if err != nil { + w.worker.Errorf("could not sort the messages: %v", err) + return nil, err + } + } + threads := builder.Threads(uids, false, false) + return threads, nil +} + +func (w *Worker) handleCreateDirectory(msg *types.CreateDirectory) error { + dir := w.c.Store.Dir(msg.Directory) + if err := dir.Init(); err != nil { + w.worker.Errorf("could not create directory %s: %v", + msg.Directory, err) + return err + } + return nil +} + +func (w *Worker) handleRemoveDirectory(msg *types.RemoveDirectory) error { + dir := w.c.Store.Dir(msg.Directory) + if err := os.RemoveAll(string(dir)); err != nil { + w.worker.Errorf("could not remove directory %s: %v", + msg.Directory, err) + return err + } + return nil +} + +func (w *Worker) handleFetchMessageHeaders( + msg *types.FetchMessageHeaders, +) error { + for _, uid := range msg.Uids { + info, err := w.msgInfoFromUid(uid) + if err != nil { + w.worker.Errorf("could not get message info: %v", err) + log.Errorf("could not get message info: %v", err) + w.worker.PostMessage(&types.MessageInfo{ + Info: &models.MessageInfo{ + Envelope: &models.Envelope{}, + Flags: models.SeenFlag, + Uid: uid, + Error: err, + }, + Message: types.RespondTo(msg), + }, nil) + continue + } + switch { + case len(w.headersExclude) > 0: + info.RFC822Headers = lib.LimitHeaders(info.RFC822Headers, w.headersExclude, true) + case len(w.headers) > 0: + info.RFC822Headers = lib.LimitHeaders(info.RFC822Headers, w.headers, false) + } + w.worker.PostMessage(&types.MessageInfo{ + Message: types.RespondTo(msg), + Info: info, + }, nil) + w.c.ClearRecentFlag(uid) + } + return nil +} + +func (w *Worker) handleFetchMessageBodyPart( + msg *types.FetchMessageBodyPart, +) error { + // get reader + m, err := w.c.Message(*w.selected, msg.Uid) + if err != nil { + w.worker.Errorf("could not get message %d: %v", msg.Uid, err) + return err + } + r, err := m.NewBodyPartReader(msg.Part) + if err != nil { + w.worker.Errorf( + "could not get body part reader for message=%d, parts=%#v: %w", + msg.Uid, msg.Part, err) + return err + } + w.worker.PostMessage(&types.MessageBodyPart{ + Message: types.RespondTo(msg), + Part: &models.MessageBodyPart{ + Reader: r, + Uid: msg.Uid, + }, + }, nil) + + return nil +} + +func (w *Worker) handleFetchFullMessages(msg *types.FetchFullMessages) error { + for _, uid := range msg.Uids { + m, err := w.c.Message(*w.selected, uid) + if err != nil { + w.worker.Errorf("could not get message %d: %v", uid, err) + return err + } + r, err := m.NewReader() + if err != nil { + w.worker.Errorf("could not get message reader: %v", err) + return err + } + defer r.Close() + b, err := io.ReadAll(r) + if err != nil { + return err + } + w.worker.PostMessage(&types.FullMessage{ + Message: types.RespondTo(msg), + Content: &models.FullMessage{ + Uid: uid, + Reader: bytes.NewReader(b), + }, + }, nil) + } + w.worker.PostMessage(&types.Done{ + Message: types.RespondTo(msg), + }, nil) + return nil +} + +func (w *Worker) handleDeleteMessages(msg *types.DeleteMessages) error { + deleted, err := w.c.DeleteAll(*w.selected, msg.Uids) + if len(deleted) > 0 { + w.worker.PostMessage(&types.MessagesDeleted{ + Message: types.RespondTo(msg), + Uids: deleted, + }, nil) + } + if err != nil { + w.worker.Errorf("failed removing messages: %v", err) + return err + } + return nil +} + +func (w *Worker) handleAnsweredMessages(msg *types.AnsweredMessages) error { + for _, uid := range msg.Uids { + m, err := w.c.Message(*w.selected, uid) + if err != nil { + w.worker.Errorf("could not get message: %v", err) + w.err(msg, err) + continue + } + if err := m.MarkReplied(msg.Answered); err != nil { + w.worker.Errorf("could not mark message as answered: %v", err) + w.err(msg, err) + continue + } + info, err := m.MessageInfo() + if err != nil { + w.worker.Errorf("could not get message info: %v", err) + w.err(msg, err) + continue + } + + w.worker.PostMessage(&types.MessageInfo{ + Message: types.RespondTo(msg), + Info: info, + }, nil) + + w.worker.PostMessage(&types.DirectoryInfo{ + Info: w.getDirectoryInfo(w.selectedName), + }, nil) + } + return nil +} + +func (w *Worker) handleForwardedMessages(msg *types.ForwardedMessages) error { + for _, uid := range msg.Uids { + m, err := w.c.Message(*w.selected, uid) + if err != nil { + w.worker.Errorf("could not get message: %v", err) + w.err(msg, err) + continue + } + if err := m.MarkForwarded(msg.Forwarded); err != nil { + w.worker.Errorf("could not mark message as answered: %v", err) + w.err(msg, err) + continue + } + + w.worker.PostMessage(&types.DirectoryInfo{ + Info: w.getDirectoryInfo(w.selectedName), + }, nil) + } + return nil +} + +func (w *Worker) handleFlagMessages(msg *types.FlagMessages) error { + for _, uid := range msg.Uids { + m, err := w.c.Message(*w.selected, uid) + if err != nil { + w.worker.Errorf("could not get message: %v", err) + w.err(msg, err) + continue + } + flag := lib.FlagToMaildir[msg.Flags] + if err := m.SetOneFlag(flag, msg.Enable); err != nil { + w.worker.Errorf("could change flag %v to %v on message: %v", flag, msg.Enable, err) + w.err(msg, err) + continue + } + info, err := m.MessageInfo() + if err != nil { + w.worker.Errorf("could not get message info: %v", err) + w.err(msg, err) + continue + } + + w.worker.PostMessage(&types.MessageInfo{ + Message: types.RespondTo(msg), + Info: info, + }, nil) + } + + w.worker.PostMessage(&types.DirectoryInfo{ + Info: w.getDirectoryInfo(w.selectedName), + }, nil) + + return nil +} + +func (w *Worker) handleCopyMessages(msg *types.CopyMessages) error { + dest := w.c.Store.Dir(msg.Destination) + err := w.c.CopyAll(dest, *w.selected, msg.Uids) + if err != nil { + return err + } + w.worker.PostMessage(&types.MessagesCopied{ + Message: types.RespondTo(msg), + Destination: msg.Destination, + Uids: msg.Uids, + }, nil) + return nil +} + +func (w *Worker) handleMoveMessages(msg *types.MoveMessages) error { + dest := w.c.Store.Dir(msg.Destination) + moved, err := w.c.MoveAll(dest, *w.selected, msg.Uids) + w.worker.PostMessage(&types.MessagesMoved{ + Message: types.RespondTo(msg), + Destination: msg.Destination, + Uids: moved, + }, nil) + w.worker.PostMessage(&types.MessagesDeleted{ + Message: types.RespondTo(msg), + Uids: moved, + }, nil) + return err +} + +func (w *Worker) handleAppendMessage(msg *types.AppendMessage) error { + // since we are the "master" maildir process, we can modify the maildir directly + dest := w.c.Store.Dir(msg.Destination) + _, writer, err := dest.Create(lib.ToMaildirFlags(msg.Flags)) + if err != nil { + return fmt.Errorf("could not create message at %s: %w", + msg.Destination, err) + } + defer writer.Close() + if _, err := io.Copy(writer, msg.Reader); err != nil { + return fmt.Errorf( + "could not write message to destination: %w", err) + } + w.worker.PostMessage(&types.Done{ + Message: types.RespondTo(msg), + }, nil) + w.worker.PostMessage(&types.DirectoryInfo{ + Info: w.getDirectoryInfo(msg.Destination), + }, nil) + return nil +} + +func (w *Worker) handleSearchDirectory(msg *types.SearchDirectory) error { + w.worker.Tracef("Searching with criteria: %#v", msg.Criteria) + uids, err := w.search(msg.Context, msg.Criteria) + if err != nil { + return err + } + w.worker.PostMessage(&types.SearchResults{ + Message: types.RespondTo(msg), + Uids: uids, + }, nil) + return nil +} + +func (w *Worker) msgInfoFromUid(uid models.UID) (*models.MessageInfo, error) { + m, err := w.c.Message(*w.selected, uid) + if err != nil { + return nil, err + } + info, err := m.MessageInfo() + if err != nil { + return nil, err + } + name, err := m.dir.Filename(m.key) + if err != nil { + return nil, err + } + info.Filenames = []string{name} + if w.c.IsRecent(uid) { + info.Flags |= models.RecentFlag + } + return info, nil +} + +func (w *Worker) msgHeadersFromUid(uid models.UID) (*models.MessageInfo, error) { + m, err := w.c.Message(*w.selected, uid) + if err != nil { + return nil, err + } + info, err := m.MessageHeaders() + if err != nil { + return nil, err + } + return info, nil +} + +func (w *Worker) handleCheckMail(msg *types.CheckMail) { + defer log.PanicHandler() + if msg.Command == "" { + w.err(msg, fmt.Errorf("checkmail: no command specified")) + return + } + ctx, cancel := context.WithTimeout(context.Background(), msg.Timeout) + defer cancel() + cmd := exec.CommandContext(ctx, "sh", "-c", msg.Command) + ch := make(chan error) + go func() { + defer log.PanicHandler() + + _, err := cmd.Output() + if err != nil { + var exitError *exec.ExitError + if errors.As(err, &exitError) { + err = fmt.Errorf("%w\n%s", err, string(exitError.Stderr)) + } + } + ch <- err + }() + select { + case <-ctx.Done(): + w.err(msg, fmt.Errorf("checkmail: timed out")) + case err := <-ch: + if err != nil { + w.err(msg, fmt.Errorf("checkmail: error running command: %w", err)) + } else { + dirs, err := w.c.Store.FolderMap() + if err != nil { + w.err(msg, fmt.Errorf("failed listing directories: %w", err)) + } + for name, dir := range dirs { + err := w.c.SyncNewMail(dir) + if err != nil { + w.err(msg, fmt.Errorf("could not sync new mail: %w", err)) + } + dirInfo := w.getDirectoryInfo(name) + w.worker.PostMessage(&types.DirectoryInfo{ + Info: dirInfo, + }, nil) + } + w.done(msg) + } + } +} diff --git a/worker/mbox/create.go b/worker/mbox/create.go new file mode 100644 index 0000000..266398a --- /dev/null +++ b/worker/mbox/create.go @@ -0,0 +1,59 @@ +package mboxer + +import ( + "io" + "os" + "path/filepath" + "strings" +) + +func createMailboxContainer(path string) (*mailboxContainer, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + + defer file.Close() + + fileInfo, err := file.Stat() + if err != nil { + return nil, err + } + + mbdata := &mailboxContainer{mailboxes: make(map[string]*container)} + + openMboxFile := func(path string, r io.Reader) error { + // read mbox file + messages, err := Read(r) + if err != nil { + return err + } + _, name := filepath.Split(path) + name = strings.TrimSuffix(name, ".mbox") + mbdata.mailboxes[name] = &container{filename: path, messages: messages} + return nil + } + + if fileInfo.IsDir() { + files, err := filepath.Glob(filepath.Join(path, "*.mbox")) + if err != nil { + return nil, err + } + for _, file := range files { + f, err := os.Open(file) + if err != nil { + continue + } + if err := openMboxFile(file, f); err != nil { + return nil, err + } + f.Close() + } + } else { + if err := openMboxFile(path, file); err != nil { + return nil, err + } + } + + return mbdata, nil +} diff --git a/worker/mbox/io.go b/worker/mbox/io.go new file mode 100644 index 0000000..22d0d02 --- /dev/null +++ b/worker/mbox/io.go @@ -0,0 +1,49 @@ +package mboxer + +import ( + "errors" + "io" + "time" + + "git.sr.ht/~rjarry/aerc/lib/rfc822" + "git.sr.ht/~rjarry/aerc/models" + "github.com/emersion/go-mbox" +) + +func Read(r io.Reader) ([]rfc822.RawMessage, error) { + mbr := mbox.NewReader(r) + messages := make([]rfc822.RawMessage, 0) + for { + msg, err := mbr.NextMessage() + if errors.Is(err, io.EOF) { + break + } else if err != nil { + return nil, err + } + + content, err := io.ReadAll(msg) + if err != nil { + return nil, err + } + + messages = append(messages, &message{ + uid: uidFromContents(content), + flags: models.SeenFlag, + content: content, + }) + } + return messages, nil +} + +func Write(w io.Writer, reader io.Reader, from string, date time.Time) error { + wc := mbox.NewWriter(w) + mw, err := wc.CreateMessage(from, time.Now()) + if err != nil { + return err + } + _, err = io.Copy(mw, reader) + if err != nil { + return err + } + return wc.Close() +} diff --git a/worker/mbox/models.go b/worker/mbox/models.go new file mode 100644 index 0000000..ebfe5d3 --- /dev/null +++ b/worker/mbox/models.go @@ -0,0 +1,185 @@ +package mboxer + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + + "git.sr.ht/~rjarry/aerc/lib/rfc822" + "git.sr.ht/~rjarry/aerc/models" +) + +type mailboxContainer struct { + mailboxes map[string]*container +} + +func (md *mailboxContainer) Names() []string { + files := make([]string, 0) + for file := range md.mailboxes { + files = append(files, file) + } + return files +} + +func (md *mailboxContainer) Mailbox(f string) (*container, bool) { + mb, ok := md.mailboxes[f] + return mb, ok +} + +func (md *mailboxContainer) Create(file string) *container { + md.mailboxes[file] = &container{filename: file} + return md.mailboxes[file] +} + +func (md *mailboxContainer) Remove(file string) error { + delete(md.mailboxes, file) + return nil +} + +func (md *mailboxContainer) DirectoryInfo(file string) *models.DirectoryInfo { + var exists int + if md, ok := md.Mailbox(file); ok { + exists = len(md.Uids()) + } + return &models.DirectoryInfo{ + Name: file, + Exists: exists, + Recent: 0, + Unseen: 0, + } +} + +func (md *mailboxContainer) Copy(dest, src string, uids []models.UID) error { + srcmbox, ok := md.Mailbox(src) + if !ok { + return fmt.Errorf("source %s not found", src) + } + destmbox, ok := md.Mailbox(dest) + if !ok { + return fmt.Errorf("destination %s not found", dest) + } + for _, uidSrc := range srcmbox.Uids() { + found := false + for _, uid := range uids { + if uid == uidSrc { + found = true + break + } + } + if found { + msg, err := srcmbox.Message(uidSrc) + if err != nil { + return fmt.Errorf("could not get message with uid %s from folder %s", uidSrc, src) + } + r, err := msg.NewReader() + if err != nil { + return fmt.Errorf("could not get reader for message with uid %s", uidSrc) + } + flags, err := msg.ModelFlags() + if err != nil { + return fmt.Errorf("could not get flags for message with uid %s", uidSrc) + } + err = destmbox.Append(r, flags) + if err != nil { + return fmt.Errorf("could not append data to mbox: %w", err) + } + } + } + md.mailboxes[dest] = destmbox + return nil +} + +type container struct { + filename string + messages []rfc822.RawMessage +} + +func (f *container) Uids() []models.UID { + uids := make([]models.UID, len(f.messages)) + for i, m := range f.messages { + uids[i] = m.UID() + } + return uids +} + +func (f *container) Message(uid models.UID) (rfc822.RawMessage, error) { + for _, m := range f.messages { + if uid == m.UID() { + return m, nil + } + } + return &message{}, fmt.Errorf("uid [%s] not found", uid) +} + +func (f *container) Delete(uids []models.UID) (deleted []models.UID) { + newMessages := make([]rfc822.RawMessage, 0) + for _, m := range f.messages { + del := false + for _, uid := range uids { + if m.UID() == uid { + del = true + break + } + } + if del { + deleted = append(deleted, m.UID()) + } else { + newMessages = append(newMessages, m) + } + } + f.messages = newMessages + return +} + +func (f *container) Append(r io.Reader, flags models.Flags) error { + data, err := io.ReadAll(r) + if err != nil { + return err + } + f.messages = append(f.messages, &message{ + uid: uidFromContents(data), + flags: flags, + content: data, + }) + return nil +} + +func uidFromContents(data []byte) models.UID { + sum := sha256.New() + sum.Write(data) + return models.UID(hex.EncodeToString(sum.Sum(nil))) +} + +// message implements the lib.RawMessage interface +type message struct { + uid models.UID + flags models.Flags + content []byte +} + +func (m *message) NewReader() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(m.content)), nil +} + +func (m *message) ModelFlags() (models.Flags, error) { + return m.flags, nil +} + +func (m *message) Labels() ([]string, error) { + return nil, nil +} + +func (m *message) UID() models.UID { + return m.uid +} + +func (m *message) SetFlag(flag models.Flags, state bool) error { + if state { + m.flags |= flag + } else { + m.flags &^= flag + } + return nil +} diff --git a/worker/mbox/worker.go b/worker/mbox/worker.go new file mode 100644 index 0000000..e8b3fa3 --- /dev/null +++ b/worker/mbox/worker.go @@ -0,0 +1,474 @@ +package mboxer + +import ( + "bytes" + "errors" + "fmt" + "io" + "net/url" + "os" + "path/filepath" + "sort" + + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/rfc822" + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/handlers" + "git.sr.ht/~rjarry/aerc/worker/lib" + "git.sr.ht/~rjarry/aerc/worker/types" +) + +func init() { + handlers.RegisterWorkerFactory("mbox", NewWorker) +} + +var errUnsupported = fmt.Errorf("unsupported command") + +type mboxWorker struct { + data *mailboxContainer + name string + folder *container + worker *types.Worker + + capabilities *models.Capabilities + headers []string + headersExclude []string +} + +func NewWorker(worker *types.Worker) (types.Backend, error) { + return &mboxWorker{ + worker: worker, + capabilities: &models.Capabilities{ + Sort: true, + Thread: false, + }, + }, nil +} + +func (w *mboxWorker) handleMessage(msg types.WorkerMessage) error { + var reterr error // will be returned at the end, needed to support idle + + switch msg := msg.(type) { + + case *types.Unsupported: + // No-op + + case *types.Configure: + u, err := url.Parse(msg.Config.Source) + if err != nil { + reterr = err + break + } + if u.Host == "" && u.Path == "" { + u, err = url.Parse("mbox://" + u.Opaque) + if err != nil { + reterr = err + break + } + } + + var dir string + if u.Host == "~" { + home, err := os.UserHomeDir() + if err != nil { + reterr = err + break + } + dir = filepath.Join(home, u.Path) + } else { + dir = filepath.Join(u.Host, u.Path) + } + w.headers = msg.Config.Headers + w.headersExclude = msg.Config.HeadersExclude + w.data, err = createMailboxContainer(dir) + if err != nil || w.data == nil { + w.data = &mailboxContainer{ + mailboxes: make(map[string]*container), + } + reterr = err + break + } else { + w.worker.Debugf("configured with mbox file %s", dir) + } + + case *types.Connect, *types.Reconnect, *types.Disconnect: + w.worker.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil) + + case *types.ListDirectories: + dirs := w.data.Names() + sort.Strings(dirs) + for _, name := range dirs { + w.worker.PostMessage(&types.Directory{ + Message: types.RespondTo(msg), + Dir: &models.Directory{ + Name: name, + }, + }, nil) + w.worker.PostMessage(&types.DirectoryInfo{ + Info: w.data.DirectoryInfo(name), + }, nil) + } + w.worker.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil) + + case *types.OpenDirectory: + w.name = msg.Directory + var ok bool + w.folder, ok = w.data.Mailbox(w.name) + if !ok { + w.folder = w.data.Create(w.name) + w.worker.PostMessage(&types.Done{ + Message: types.RespondTo(&types.CreateDirectory{}), + }, nil) + } + w.worker.PostMessage(&types.DirectoryInfo{ + Info: w.data.DirectoryInfo(msg.Directory), + }, nil) + w.worker.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil) + w.worker.Debugf("%s opened", msg.Directory) + + case *types.FetchDirectoryContents: + uids, err := filterUids(w.folder, w.folder.Uids(), msg.Filter) + if err != nil { + reterr = err + break + } + uids, err = sortUids(w.folder, uids, msg.SortCriteria) + if err != nil { + reterr = err + break + } + if len(uids) == 0 { + reterr = fmt.Errorf("mbox: no uids in directory") + break + } + w.worker.PostMessage(&types.DirectoryContents{ + Message: types.RespondTo(msg), + Uids: uids, + }, nil) + w.worker.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil) + + case *types.FetchDirectoryThreaded: + reterr = errUnsupported + + case *types.CreateDirectory: + w.data.Create(msg.Directory) + w.worker.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil) + + case *types.RemoveDirectory: + if err := w.data.Remove(msg.Directory); err != nil { + reterr = err + break + } + w.worker.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil) + + case *types.FetchMessageHeaders: + for _, uid := range msg.Uids { + m, err := w.folder.Message(uid) + if err != nil { + reterr = err + break + } + msgInfo, err := messageInfo(m, true) + if err != nil { + w.worker.PostMessage(&types.MessageInfo{ + Info: &models.MessageInfo{ + Envelope: &models.Envelope{}, + Flags: models.SeenFlag, + Uid: uid, + Error: err, + }, + Message: types.RespondTo(msg), + }, nil) + continue + } else { + switch { + case len(w.headersExclude) > 0: + msgInfo.RFC822Headers = lib.LimitHeaders(msgInfo.RFC822Headers, w.headersExclude, true) + case len(w.headers) > 0: + msgInfo.RFC822Headers = lib.LimitHeaders(msgInfo.RFC822Headers, w.headers, false) + } + w.worker.PostMessage(&types.MessageInfo{ + Message: types.RespondTo(msg), + Info: msgInfo, + }, nil) + } + } + w.worker.PostMessage( + &types.Done{Message: types.RespondTo(msg)}, nil) + + case *types.FetchMessageBodyPart: + m, err := w.folder.Message(msg.Uid) + if err != nil { + w.worker.Errorf("could not get message %d: %v", msg.Uid, err) + reterr = err + break + } + + contentReader, err := m.NewReader() + if err != nil { + reterr = fmt.Errorf("could not get message reader: %w", err) + break + } + + fullMsg, err := rfc822.ReadMessage(contentReader) + if err != nil { + reterr = fmt.Errorf("could not read message: %w", err) + break + } + + r, err := rfc822.FetchEntityPartReader(fullMsg, msg.Part) + if err != nil { + w.worker.Errorf( + "could not get body part reader for message=%d, parts=%#v: %w", + msg.Uid, msg.Part, err) + reterr = err + break + } + + w.worker.PostMessage(&types.MessageBodyPart{ + Message: types.RespondTo(msg), + Part: &models.MessageBodyPart{ + Reader: r, + Uid: msg.Uid, + }, + }, nil) + + case *types.FetchFullMessages: + for _, uid := range msg.Uids { + m, err := w.folder.Message(uid) + if err != nil { + w.worker.Errorf("could not get message for uid %d: %v", uid, err) + continue + } + r, err := m.NewReader() + if err != nil { + w.worker.Errorf("could not get message reader: %v", err) + continue + } + defer r.Close() + b, err := io.ReadAll(r) + if err != nil { + w.worker.Errorf("could not get message reader: %v", err) + continue + } + w.worker.PostMessage(&types.FullMessage{ + Message: types.RespondTo(msg), + Content: &models.FullMessage{ + Uid: uid, + Reader: bytes.NewReader(b), + }, + }, nil) + } + w.worker.PostMessage(&types.Done{ + Message: types.RespondTo(msg), + }, nil) + + case *types.DeleteMessages: + deleted := w.folder.Delete(msg.Uids) + if len(deleted) > 0 { + w.worker.PostMessage(&types.MessagesDeleted{ + Message: types.RespondTo(msg), + Uids: deleted, + }, nil) + } + + w.worker.PostMessage(&types.DirectoryInfo{ + Info: w.data.DirectoryInfo(w.name), + }, nil) + + w.worker.PostMessage( + &types.Done{Message: types.RespondTo(msg)}, nil) + + case *types.FlagMessages: + for _, uid := range msg.Uids { + m, err := w.folder.Message(uid) + if err != nil { + w.worker.Errorf("could not get message: %v", err) + continue + } + if err := m.(*message).SetFlag(msg.Flags, msg.Enable); err != nil { + w.worker.Errorf("could not change flag %v to %t on message: %v", + msg.Flags, msg.Enable, err) + continue + } + info, err := rfc822.MessageInfo(m) + if err != nil { + w.worker.Errorf("could not get message info: %v", err) + continue + } + + w.worker.PostMessage(&types.MessageInfo{ + Message: types.RespondTo(msg), + Info: info, + }, nil) + } + + w.worker.PostMessage(&types.DirectoryInfo{ + Info: w.data.DirectoryInfo(w.name), + }, nil) + + w.worker.PostMessage( + &types.Done{Message: types.RespondTo(msg)}, nil) + + case *types.CopyMessages: + err := w.data.Copy(msg.Destination, w.name, msg.Uids) + if err != nil { + reterr = err + break + } + + w.worker.PostMessage(&types.DirectoryInfo{ + Info: w.data.DirectoryInfo(w.name), + }, nil) + + w.worker.PostMessage(&types.DirectoryInfo{ + Info: w.data.DirectoryInfo(msg.Destination), + }, nil) + + w.worker.PostMessage( + &types.Done{Message: types.RespondTo(msg)}, nil) + case *types.MoveMessages: + err := w.data.Copy(msg.Destination, w.name, msg.Uids) + if err != nil { + reterr = err + break + } + deleted := w.folder.Delete(msg.Uids) + if len(deleted) > 0 { + w.worker.PostMessage(&types.MessagesDeleted{ + Message: types.RespondTo(msg), + Uids: deleted, + }, nil) + } + w.worker.PostMessage(&types.DirectoryInfo{ + Info: w.data.DirectoryInfo(msg.Destination), + }, nil) + w.worker.PostMessage( + &types.Done{Message: types.RespondTo(msg)}, nil) + + case *types.SearchDirectory: + uids, err := filterUids(w.folder, w.folder.Uids(), msg.Criteria) + if err != nil { + reterr = err + break + } + w.worker.PostMessage(&types.SearchResults{ + Message: types.RespondTo(msg), + Uids: uids, + }, nil) + + case *types.AppendMessage: + if msg.Destination == "" { + reterr = fmt.Errorf("AppendMessage with empty destination directory") + break + } + folder, ok := w.data.Mailbox(msg.Destination) + if !ok { + folder = w.data.Create(msg.Destination) + w.worker.PostMessage(&types.Done{ + Message: types.RespondTo(&types.CreateDirectory{}), + }, nil) + } + + if err := folder.Append(msg.Reader, msg.Flags); err != nil { + reterr = err + break + } else { + w.worker.PostMessage(&types.DirectoryInfo{ + Info: w.data.DirectoryInfo(msg.Destination), + }, nil) + w.worker.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil) + } + + case *types.AnsweredMessages: + reterr = errUnsupported + default: + reterr = errUnsupported + } + + return reterr +} + +func (w *mboxWorker) Run() { + for msg := range w.worker.Actions() { + msg = w.worker.ProcessAction(msg) + if err := w.handleMessage(msg); errors.Is(err, errUnsupported) { + w.worker.PostMessage(&types.Unsupported{ + Message: types.RespondTo(msg), + }, nil) + } else if err != nil { + w.worker.PostMessage(&types.Error{ + Message: types.RespondTo(msg), + Error: err, + }, nil) + } + } +} + +func (w *mboxWorker) Capabilities() *models.Capabilities { + return w.capabilities +} + +func (w *mboxWorker) PathSeparator() string { + return "/" +} + +func filterUids(folder *container, uids []models.UID, criteria *types.SearchCriteria) ([]models.UID, error) { + log.Debugf("Search with parsed criteria: %#v", criteria) + m := make([]rfc822.RawMessage, 0, len(uids)) + for _, uid := range uids { + msg, err := folder.Message(uid) + if err != nil { + log.Errorf("failed to get message for uid: %d", uid) + continue + } + m = append(m, msg) + } + return lib.Search(m, criteria) +} + +func sortUids(folder *container, uids []models.UID, + criteria []*types.SortCriterion, +) ([]models.UID, error) { + var infos []*models.MessageInfo + needSize := false + for _, item := range criteria { + if item.Field == types.SortSize { + needSize = true + } + } + for _, uid := range uids { + m, err := folder.Message(uid) + if err != nil { + log.Errorf("could not get message %v", err) + continue + } + info, err := messageInfo(m, needSize) + if err != nil { + log.Errorf("could not get message info %v", err) + continue + } + infos = append(infos, info) + } + return lib.Sort(infos, criteria) +} + +func messageInfo(m rfc822.RawMessage, needSize bool) (*models.MessageInfo, error) { + info, err := rfc822.MessageInfo(m) + if err != nil { + return nil, err + } + if !needSize { + return info, nil + } + r, err := m.NewReader() + if err != nil { + return nil, err + } + size, err := io.Copy(io.Discard, r) + if err != nil { + return nil, err + } + info.Size = uint32(size) + return info, nil +} diff --git a/worker/middleware/foldermapper.go b/worker/middleware/foldermapper.go new file mode 100644 index 0000000..ba09819 --- /dev/null +++ b/worker/middleware/foldermapper.go @@ -0,0 +1,179 @@ +package middleware + +import ( + "fmt" + "strings" + "sync" + + "git.sr.ht/~rjarry/aerc/worker/types" +) + +type folderMapper struct { + sync.Mutex + types.WorkerInteractor + fm folderMap + table map[string]string +} + +func NewFolderMapper(base types.WorkerInteractor, mapping map[string]string, + order []string, +) types.WorkerInteractor { + base.Infof("loading worker middleware: foldermapper") + return &folderMapper{ + WorkerInteractor: base, + fm: folderMap{mapping, order}, + table: make(map[string]string), + } +} + +func (f *folderMapper) Unwrap() types.WorkerInteractor { + return f.WorkerInteractor +} + +func (f *folderMapper) incoming(msg types.WorkerMessage, dir string) string { + f.Lock() + defer f.Unlock() + mapped, ok := f.table[dir] + if !ok { + return dir + } + return mapped +} + +func (f *folderMapper) outgoing(msg types.WorkerMessage, dir string) string { + f.Lock() + defer f.Unlock() + for k, v := range f.table { + if v == dir { + mapped := k + return mapped + } + } + return dir +} + +func (f *folderMapper) store(s string) { + f.Lock() + defer f.Unlock() + display := f.fm.Apply(s) + f.table[display] = s + f.Tracef("store display folder '%s' to '%s'", display, s) +} + +func (f *folderMapper) create(s string) (string, error) { + f.Lock() + defer f.Unlock() + backend := createFolder(f.table, s) + if _, exists := f.table[s]; exists { + return s, fmt.Errorf("folder already exists: %s", s) + } + f.table[s] = backend + f.Tracef("create display folder '%s' as '%s'", s, backend) + return backend, nil +} + +func (f *folderMapper) ProcessAction(msg types.WorkerMessage) types.WorkerMessage { + switch msg := msg.(type) { + case *types.CheckMail: + for i := range msg.Directories { + msg.Directories[i] = f.incoming(msg, msg.Directories[i]) + } + case *types.CopyMessages: + msg.Destination = f.incoming(msg, msg.Destination) + case *types.AppendMessage: + msg.Destination = f.incoming(msg, msg.Destination) + case *types.MoveMessages: + msg.Destination = f.incoming(msg, msg.Destination) + case *types.CreateDirectory: + var err error + msg.Directory, err = f.create(msg.Directory) + if err != nil { + f.Errorf("error creating new directory: %v", err) + } + case *types.RemoveDirectory: + msg.Directory = f.incoming(msg, msg.Directory) + case *types.OpenDirectory: + msg.Directory = f.incoming(msg, msg.Directory) + } + + return f.WorkerInteractor.ProcessAction(msg) +} + +func (f *folderMapper) PostMessage(msg types.WorkerMessage, cb func(m types.WorkerMessage)) { + switch msg := msg.(type) { + case *types.Done: + switch msg := msg.InResponseTo().(type) { + case *types.CheckMail: + for i := range msg.Directories { + msg.Directories[i] = f.outgoing(msg, msg.Directories[i]) + } + case *types.CopyMessages: + msg.Destination = f.outgoing(msg, msg.Destination) + case *types.AppendMessage: + msg.Destination = f.outgoing(msg, msg.Destination) + case *types.MoveMessages: + msg.Destination = f.outgoing(msg, msg.Destination) + case *types.CreateDirectory: + msg.Directory = f.outgoing(msg, msg.Directory) + case *types.RemoveDirectory: + msg.Directory = f.outgoing(msg, msg.Directory) + case *types.OpenDirectory: + msg.Directory = f.outgoing(msg, msg.Directory) + } + case *types.CheckMailDirectories: + for i := range msg.Directories { + msg.Directories[i] = f.outgoing(msg, msg.Directories[i]) + } + case *types.Directory: + f.store(msg.Dir.Name) + msg.Dir.Name = f.outgoing(msg, msg.Dir.Name) + case *types.DirectoryInfo: + msg.Info.Name = f.outgoing(msg, msg.Info.Name) + } + f.WorkerInteractor.PostMessage(msg, cb) +} + +// folderMap contains the mapping between the ui and backend folder names +type folderMap struct { + mapping map[string]string + order []string +} + +// Apply applies the mapping from the folder map to the backend folder +func (f *folderMap) Apply(s string) string { + for _, k := range f.order { + v := f.mapping[k] + strict := true + if strings.HasSuffix(v, "*") { + v = strings.TrimSuffix(v, "*") + strict = false + } + if (strings.HasPrefix(s, v) && !strict) || (s == v && strict) { + term := strings.TrimPrefix(s, v) + if strings.Contains(k, "*") && !strict { + prefix := k + for strings.Contains(prefix, "**") { + prefix = strings.ReplaceAll(prefix, "**", "*") + } + s = strings.Replace(prefix, "*", term, 1) + } else { + s = k + term + } + } + } + return s +} + +// createFolder reverses the mapping of a new folder name +func createFolder(table map[string]string, s string) string { + max, key := 0, "" + for k := range table { + if strings.HasPrefix(s, k) && len(k) > max { + max, key = len(k), k + } + } + if max > 0 && key != "" { + s = table[key] + strings.TrimPrefix(s, key) + } + return s +} diff --git a/worker/middleware/foldermapper_test.go b/worker/middleware/foldermapper_test.go new file mode 100644 index 0000000..0b4f6db --- /dev/null +++ b/worker/middleware/foldermapper_test.go @@ -0,0 +1,103 @@ +package middleware + +import ( + "reflect" + "testing" +) + +func TestFolderMap_Apply(t *testing.T) { + tests := []struct { + name string + mapping map[string]string + order []string + input []string + want []string + }{ + { + name: "strict single folder mapping", + mapping: map[string]string{"Drafts": "INBOX/Drafts"}, + order: []string{"Drafts"}, + input: []string{"INBOX/Drafts"}, + want: []string{"Drafts"}, + }, + { + name: "prefix mapping with * suffix", + mapping: map[string]string{"Prefix/": "INBOX/*"}, + order: []string{"Prefix/"}, + input: []string{"INBOX", "INBOX/Test1", "INBOX/Test2", "Archive"}, + want: []string{"INBOX", "Prefix/Test1", "Prefix/Test2", "Archive"}, + }, + { + name: "remove prefix with * in key", + mapping: map[string]string{"*": "INBOX/*"}, + order: []string{"*"}, + input: []string{"INBOX", "INBOX/Test1", "INBOX/Test2", "Archive"}, + want: []string{"INBOX", "Test1", "Test2", "Archive"}, + }, + { + name: "remove two prefixes with * in keys", + mapping: map[string]string{ + "*": "INBOX/*", + "**": "PROJECT/*", + }, + order: []string{"*", "**"}, + input: []string{"INBOX", "INBOX/Test1", "INBOX/Test2", "Archive", "PROJECT/sub1", "PROJECT/sub2"}, + want: []string{"INBOX", "Test1", "Test2", "Archive", "sub1", "sub2"}, + }, + { + name: "multiple, sequential mappings", + mapping: map[string]string{ + "Archive/existing": "Archive*", + "Archive": "Archivum*", + }, + order: []string{"Archive/existing", "Archive"}, + input: []string{"Archive", "Archive/sub", "Archivum", "Archivum/year1"}, + want: []string{"Archive/existing", "Archive/existing/sub", "Archive", "Archive/year1"}, + }, + } + + for i, test := range tests { + fm := &folderMap{ + mapping: test.mapping, + order: test.order, + } + var result []string + for _, in := range test.input { + result = append(result, fm.Apply(in)) + } + if !reflect.DeepEqual(result, test.want) { + t.Errorf("test (%d: %s) failed: want '%v' but got '%v'", + i, test.name, test.want, result) + } + } +} + +func TestFolderMap_createFolder(t *testing.T) { + tests := []struct { + name string + table map[string]string + input string + want string + }{ + { + name: "create normal folder", + table: map[string]string{"Drafts": "INBOX/Drafts"}, + input: "INBOX/Drafts2", + want: "INBOX/Drafts2", + }, + { + name: "create mapped folder", + table: map[string]string{"Drafts": "INBOX/Drafts"}, + input: "Drafts/Sub", + want: "INBOX/Drafts/Sub", + }, + } + + for i, test := range tests { + result := createFolder(test.table, test.input) + if result != test.want { + t.Errorf("test (%d: %s) failed: want '%v' but got '%v'", + i, test.name, test.want, result) + } + } +} diff --git a/worker/middleware/gmailworker.go b/worker/middleware/gmailworker.go new file mode 100644 index 0000000..4f5f545 --- /dev/null +++ b/worker/middleware/gmailworker.go @@ -0,0 +1,133 @@ +package middleware + +import ( + "strconv" + "strings" + "sync" + + "git.sr.ht/~rjarry/aerc/worker/imap/extensions/xgmext" + "git.sr.ht/~rjarry/aerc/worker/types" + "github.com/emersion/go-imap/client" +) + +type gmailWorker struct { + types.WorkerInteractor + mu sync.Mutex + client *client.Client +} + +// NewGmailWorker returns an IMAP middleware for the X-GM-EXT-1 extension +func NewGmailWorker(base types.WorkerInteractor, c *client.Client, +) types.WorkerInteractor { + base.Infof("loading worker middleware: X-GM-EXT-1") + + // avoid double wrapping; unwrap and check for another gmail handler + for iter := base; iter != nil; iter = iter.Unwrap() { + if g, ok := iter.(*gmailWorker); ok { + base.Infof("already loaded; resetting") + err := g.reset(c) + if err != nil { + base.Errorf("reset failed: %v", err) + } + return base + } + } + return &gmailWorker{WorkerInteractor: base, client: c} +} + +func (g *gmailWorker) Unwrap() types.WorkerInteractor { + return g.WorkerInteractor +} + +func (g *gmailWorker) reset(c *client.Client) error { + g.mu.Lock() + defer g.mu.Unlock() + g.client = c + return nil +} + +func (g *gmailWorker) ProcessAction(msg types.WorkerMessage) types.WorkerMessage { + switch msg := msg.(type) { + case *types.FetchMessageHeaders: + handler := xgmext.NewHandler(g.client) + + g.mu.Lock() + uids, err := handler.FetchEntireThreads(msg.Uids) + g.mu.Unlock() + if err != nil { + g.Warnf("failed to fetch entire threads: %v", err) + } + + if len(uids) > 0 { + msg.Uids = uids + } + + case *types.FetchDirectoryContents: + if msg.Filter == nil || (msg.Filter != nil && + len(msg.Filter.Terms) == 0) { + break + } + if !msg.Filter.UseExtension { + g.Debugf("use regular imap filter instead of X-GM-EXT1: " + + "extension flag not set") + break + } + + search := strings.Join(msg.Filter.Terms, " ") + g.Debugf("X-GM-EXT1 filter term: '%s'", search) + + handler := xgmext.NewHandler(g.client) + + g.mu.Lock() + uids, err := handler.RawSearch(strconv.Quote(search)) + g.mu.Unlock() + if err != nil { + g.Errorf("X-GM-EXT1 filter failed: %v", err) + g.Warnf("falling back to imap filtering") + break + } + + g.PostMessage(&types.DirectoryContents{ + Message: types.RespondTo(msg), + Uids: uids, + }, nil) + + g.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil) + + return &types.Unsupported{} + + case *types.SearchDirectory: + if msg.Criteria == nil || (msg.Criteria != nil && + len(msg.Criteria.Terms) == 0) { + break + } + if !msg.Criteria.UseExtension { + g.Debugf("use regular imap search instead of X-GM-EXT1: " + + "extension flag not set") + break + } + + search := strings.Join(msg.Criteria.Terms, " ") + g.Debugf("X-GM-EXT1 search term: '%s'", search) + handler := xgmext.NewHandler(g.client) + + g.mu.Lock() + uids, err := handler.RawSearch(strconv.Quote(search)) + g.mu.Unlock() + if err != nil { + g.Errorf("X-GM-EXT1 search failed: %v", err) + g.Warnf("falling back to regular imap search.") + break + } + + g.PostMessage(&types.SearchResults{ + Message: types.RespondTo(msg), + Uids: uids, + }, nil) + + g.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil) + + return &types.Unsupported{} + } + return g.WorkerInteractor.ProcessAction(msg) +} diff --git a/worker/notmuch/eventhandlers.go b/worker/notmuch/eventhandlers.go new file mode 100644 index 0000000..fb454e0 --- /dev/null +++ b/worker/notmuch/eventhandlers.go @@ -0,0 +1,92 @@ +//go:build notmuch +// +build notmuch + +package notmuch + +import ( + "context" + "fmt" + "path/filepath" + "strconv" + + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/worker/types" +) + +func (w *worker) handleNotmuchEvent() error { + err := w.db.Connect() + if err != nil { + return err + } + defer w.db.Close() + err = w.updateDirCounts() + if err != nil { + return err + } + err = w.updateChangedMessages() + if err != nil { + return err + } + w.emitLabelList() + return nil +} + +func (w *worker) updateDirCounts() error { + if w.store != nil { + folders, err := w.store.FolderMap() + if err != nil { + w.w.Errorf("failed listing directories: %v", err) + return err + } + for name := range folders { + folder := filepath.Join(w.maildirAccountPath, name) + query := fmt.Sprintf("folder:%s", strconv.Quote(folder)) + w.w.PostMessage(&types.DirectoryInfo{ + Info: w.getDirectoryInfo(name, query), + Refetch: w.query == query, + }, nil) + } + } + + for name, query := range w.nameQueryMap { + w.w.PostMessage(&types.DirectoryInfo{ + Info: w.getDirectoryInfo(name, query), + Refetch: w.query == query, + }, nil) + } + + for name, query := range w.dynamicNameQueryMap { + w.w.PostMessage(&types.DirectoryInfo{ + Info: w.getDirectoryInfo(name, query), + Refetch: w.query == query, + }, nil) + } + + return nil +} + +func (w *worker) updateChangedMessages() error { + newState := w.db.State() + if newState == w.state { + return nil + } + w.w.Logger.Debugf("State change: %d to %d", w.state, newState) + query := fmt.Sprintf("lastmod:%d..%d and (%s)", w.state, newState, w.query) + uids, err := w.uidsFromQuery(context.TODO(), query) + if err != nil { + return fmt.Errorf("Couldn't get updates messages: %w", err) + } + for _, uid := range uids { + m, err := w.msgFromUid(uid) + if err != nil { + log.Errorf("%s", err) + continue + } + err = w.emitMessageInfo(m, nil) + if err != nil { + log.Errorf("%s", err) + } + } + w.state = newState + return nil +} diff --git a/worker/notmuch/lib/database.go b/worker/notmuch/lib/database.go new file mode 100644 index 0000000..b4643a5 --- /dev/null +++ b/worker/notmuch/lib/database.go @@ -0,0 +1,372 @@ +//go:build notmuch +// +build notmuch + +package lib + +import ( + "context" + "errors" + "fmt" + + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/notmuch" + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/types" +) + +type DB struct { + path string + excludedTags []string + db *notmuch.Database +} + +func NewDB(path string, excludedTags []string) *DB { + nm := ¬much.Database{ + Path: path, + } + db := &DB{ + path: path, + excludedTags: excludedTags, + db: nm, + } + return db +} + +func (db *DB) Connect() error { + return db.db.Open(notmuch.MODE_READ_ONLY) +} + +func (db *DB) Close() error { + return db.db.Close() +} + +// Returns the DB path +func (db *DB) Path() string { + return db.db.ResolvedPath() +} + +// ListTags lists all known tags +func (db *DB) ListTags() []string { + return db.db.Tags() +} + +// State returns the lastmod of the database. This is a uin64 which is +// incremented with every modification +func (db *DB) State() uint64 { + _, lastmod := db.db.Revision() + return lastmod +} + +// getQuery returns a query based on the provided query string. +// It also configures the query as specified on the worker +func (db *DB) newQuery(query string) (*notmuch.Query, error) { + q, err := db.db.Query(query) + if err != nil { + return nil, err + } + q.Exclude(notmuch.EXCLUDE_ALL) + q.Sort(notmuch.SORT_OLDEST_FIRST) + for _, t := range db.excludedTags { + err := q.ExcludeTag(t) + // do not treat STATUS_IGNORED as an error; this allows explicit + // searches using tags that are excluded by default + if err != nil && !errors.Is(err, notmuch.STATUS_IGNORED) { + return nil, err + } + } + return &q, nil +} + +func (db *DB) MsgIDFromFilename(filename string) (string, error) { + msg, err := db.db.FindMessageByFilename(filename) + if err != nil { + return "", err + } + defer msg.Close() + return msg.ID(), nil +} + +func (db *DB) MsgIDsFromQuery(ctx context.Context, q string) ([]string, error) { + query, err := db.newQuery(q) + if err != nil { + return nil, err + } + defer query.Close() + messages, err := query.Messages() + if err != nil { + return nil, err + } + defer messages.Close() + var msgIDs []string + for messages.Next() { + select { + case <-ctx.Done(): + return nil, context.Canceled + default: + msg := messages.Message() + defer msg.Close() + msgIDs = append(msgIDs, msg.ID()) + } + } + return msgIDs, err +} + +func (db *DB) ThreadsFromQuery(ctx context.Context, q string, entireThread bool) ([]*types.Thread, error) { + query, err := db.newQuery(q) + if err != nil { + return nil, err + } + defer query.Close() + // To get proper ordering of threads, we always sort newest first + query.Sort(notmuch.SORT_NEWEST_FIRST) + threads, err := query.Threads() + if err != nil { + return nil, err + } + n, err := query.CountMessages() + if err != nil { + return nil, err + } + defer threads.Close() + res := make([]*types.Thread, 0, n) + for threads.Next() { + select { + case <-ctx.Done(): + return nil, context.Canceled + default: + thread := threads.Thread() + tlm := thread.TopLevelMessages() + root := db.makeThread(nil, &tlm, entireThread) + if len(root) > 1 { + root[0].Dummy = true + fc := &(root[0].FirstChild) + for ; *fc != nil; fc = &((*fc).NextSibling) { + } + *fc = root[0].NextSibling + root[0].NextSibling.PrevSibling = nil + root[0].NextSibling = nil + for i := 1; i < len(root); i++ { + root[i].Parent = root[0] + } + res = append(res, root[0]) + } else { + res = append(res, root...) + } + tlm.Close() + thread.Close() + } + } + // Reverse the slice + for i, j := 0, len(res)-1; i < j; i, j = i+1, j-1 { + res[i], res[j] = res[j], res[i] + } + return res, err +} + +type MessageCount struct { + Exists int + Unread int +} + +func (db *DB) QueryCountMessages(q string) (MessageCount, error) { + count := MessageCount{} + query, err := db.newQuery(q) + if err != nil { + return count, err + } + defer query.Close() + count.Exists, err = query.CountMessages() + if err != nil { + return count, err + } + + unreadQuery, err := db.newQuery(AndQueries(q, "tag:unread")) + if err != nil { + return count, err + } + defer unreadQuery.Close() + count.Unread, err = unreadQuery.CountMessages() + if err != nil { + return count, err + } + + return count, nil +} + +func (db *DB) MsgFilename(key string) (string, error) { + msg, err := db.db.FindMessageByID(key) + if err != nil { + return "", err + } + defer msg.Close() + return msg.Filename(), nil +} + +func (db *DB) MsgTags(key string) ([]string, error) { + msg, err := db.db.FindMessageByID(key) + if err != nil { + return nil, err + } + defer msg.Close() + return msg.Tags(), nil +} + +func (db *DB) MsgFilenames(key string) ([]string, error) { + msg, err := db.db.FindMessageByID(key) + if err != nil { + return nil, err + } + defer msg.Close() + return msg.Filenames(), nil +} + +func (db *DB) DeleteMessage(filename string) error { + err := db.db.Reopen(notmuch.MODE_READ_WRITE) + if err != nil { + return err + } + defer func() { + if err := db.db.Reopen(notmuch.MODE_READ_ONLY); err != nil { + log.Errorf("couldn't reopen: %s", err) + } + }() + err = db.db.BeginAtomic() + if err != nil { + return err + } + defer func() { + if err := db.db.EndAtomic(); err != nil { + log.Errorf("couldn't end atomic: %s", err) + } + }() + err = db.db.RemoveFile(filename) + if err != nil && !errors.Is(err, notmuch.STATUS_DUPLICATE_MESSAGE_ID) { + return err + } + return nil +} + +func (db *DB) IndexFile(filename string) (string, error) { + err := db.db.Reopen(notmuch.MODE_READ_WRITE) + if err != nil { + return "", err + } + defer func() { + if err := db.db.Reopen(notmuch.MODE_READ_ONLY); err != nil { + log.Errorf("couldn't reopen: %s", err) + } + }() + err = db.db.BeginAtomic() + if err != nil { + return "", err + } + defer func() { + if err := db.db.EndAtomic(); err != nil { + log.Errorf("couldn't end atomic: %s", err) + } + }() + msg, err := db.db.IndexFile(filename) + if err != nil { + return "", err + } + defer msg.Close() + return msg.ID(), nil +} + +func (db *DB) MsgModifyTags(key string, add, remove []string) error { + err := db.db.Reopen(notmuch.MODE_READ_WRITE) + if err != nil { + return err + } + defer func() { + if err := db.db.Reopen(notmuch.MODE_READ_ONLY); err != nil { + log.Errorf("couldn't reopen: %s", err) + } + }() + err = db.db.BeginAtomic() + if err != nil { + return err + } + defer func() { + if err := db.db.EndAtomic(); err != nil { + log.Errorf("couldn't end atomic: %s", err) + } + }() + msg, err := db.db.FindMessageByID(key) + if err != nil { + return err + } + defer msg.Close() + for _, tag := range add { + err := msg.AddTag(tag) + if err != nil { + log.Warnf("failed to add tag: %v", err) + } + } + for _, tag := range remove { + err := msg.RemoveTag(tag) + if err != nil { + log.Warnf("failed to add tag: %v", err) + } + } + return msg.SyncTagsToMaildirFlags() +} + +func (db *DB) makeThread(parent *types.Thread, msgs *notmuch.Messages, threadContext bool) []*types.Thread { + var siblings []*types.Thread + for msgs.Next() { + msg := msgs.Message() + defer msg.Close() + msgID := msg.ID() + match, err := msg.Flag(notmuch.MESSAGE_FLAG_MATCH) + if err != nil { + log.Errorf("%s", err) + continue + } + replies := msg.Replies() + defer replies.Close() + if !match && !threadContext { + siblings = append(siblings, db.makeThread(parent, &replies, threadContext)...) + continue + } + node := &types.Thread{ + Uid: models.UID(msgID), + Parent: parent, + } + switch threadContext { + case true: + node.Context = !match + default: + if match { + node.Hidden = 0 + } else { + node.Hidden = 1 + } + } + if parent != nil && parent.FirstChild == nil { + parent.FirstChild = node + } + siblings = append(siblings, node) + db.makeThread(node, &replies, threadContext) + } + for i := 1; i < len(siblings); i++ { + siblings[i-1].NextSibling = siblings[i] + } + return siblings +} + +func AndQueries(q1, q2 string) string { + if q1 == "" { + return q2 + } + if q2 == "" { + return q1 + } + if q1 == "*" { + return q2 + } + if q2 == "*" { + return q1 + } + return fmt.Sprintf("(%s) and (%s)", q1, q2) +} diff --git a/worker/notmuch/message.go b/worker/notmuch/message.go new file mode 100644 index 0000000..81a4da5 --- /dev/null +++ b/worker/notmuch/message.go @@ -0,0 +1,334 @@ +//go:build notmuch +// +build notmuch + +package notmuch + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/emersion/go-maildir" + + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/rfc822" + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/lib" + notmuch "git.sr.ht/~rjarry/aerc/worker/notmuch/lib" + "git.sr.ht/~rjarry/aerc/worker/types" +) + +type Message struct { + uid models.UID + key string + db *notmuch.DB +} + +// NewReader returns a reader for a message +func (m *Message) NewReader() (io.ReadCloser, error) { + name, err := m.Filename() + if err != nil { + return nil, err + } + return os.Open(name) +} + +// MessageInfo populates a models.MessageInfo struct for the message. +func (m *Message) MessageInfo() (*models.MessageInfo, error) { + info, err := rfc822.MessageInfo(m) + if err != nil { + return nil, err + } + if filenames, err := m.db.MsgFilenames(m.key); err != nil { + log.Errorf("failed to obtain filenames: %v", err) + } else { + info.Filenames = filenames + // if size retrieval fails, just return info and log error + if len(filenames) > 0 { + if info.Size, err = lib.FileSize(filenames[0]); err != nil { + log.Errorf("failed to obtain file size: %v", err) + } + } + } + return info, nil +} + +// NewBodyPartReader creates a new io.Reader for the requested body part(s) of +// the message. +func (m *Message) NewBodyPartReader(requestedParts []int) (io.Reader, error) { + name, err := m.Filename() + if err != nil { + return nil, err + } + f, err := os.Open(name) + if err != nil { + return nil, err + } + defer f.Close() + msg, err := rfc822.ReadMessage(f) + if err != nil { + return nil, fmt.Errorf("could not read message: %w", err) + } + return rfc822.FetchEntityPartReader(msg, requestedParts) +} + +// SetFlag adds or removes a flag from the message. +// Notmuch doesn't support all the flags, and for those this errors. +func (m *Message) SetFlag(flag models.Flags, enable bool) error { + // Translate the flag into a notmuch tag, ignoring no-op flags. + tag, ok := flagToTag[flag] + if !ok { + return fmt.Errorf("Notmuch doesn't support flag %v", flag) + } + + // Get the current state of the flag. + // Note that notmuch handles some flags in an inverted sense + oldState := false + tags, err := m.Tags() + if err != nil { + return err + } + for _, t := range tags { + if t == tag { + oldState = true + break + } + } + + if flagToInvert[flag] { + enable = !enable + } + + switch { + case oldState == enable: + return nil + case enable: + return m.AddTag(tag) + default: + return m.RemoveTag(tag) + } +} + +// MarkAnswered either adds or removes the "replied" tag from the message. +func (m *Message) MarkAnswered(answered bool) error { + return m.SetFlag(models.AnsweredFlag, answered) +} + +// MarkForwarded either adds or removes the "forwarded" tag from the message. +func (m *Message) MarkForwarded(forwarded bool) error { + return m.SetFlag(models.ForwardedFlag, forwarded) +} + +// MarkRead either adds or removes the maildir.FlagSeen flag from the message. +func (m *Message) MarkRead(seen bool) error { + return m.SetFlag(models.SeenFlag, seen) +} + +// tags returns the notmuch tags of a message +func (m *Message) Tags() ([]string, error) { + return m.db.MsgTags(m.key) +} + +func (m *Message) Labels() ([]string, error) { + return m.Tags() +} + +func (m *Message) ModelFlags() (models.Flags, error) { + var flags models.Flags = models.SeenFlag + tags, err := m.Tags() + if err != nil { + return 0, err + } + for _, tag := range tags { + flag := tagToFlag[tag] + if flagToInvert[flag] { + flags &^= flag + } else { + flags |= flag + } + } + return flags, nil +} + +func (m *Message) UID() models.UID { + return m.uid +} + +func (m *Message) Filename() (string, error) { + return m.db.MsgFilename(m.key) +} + +// AddTag adds a single tag. +// Consider using *Message.ModifyTags for multiple additions / removals +// instead of looping over a tag array +func (m *Message) AddTag(tag string) error { + return m.ModifyTags([]string{tag}, nil) +} + +// RemoveTag removes a single tag. +// Consider using *Message.ModifyTags for multiple additions / removals +// instead of looping over a tag array +func (m *Message) RemoveTag(tag string) error { + return m.ModifyTags(nil, []string{tag}) +} + +func (m *Message) ModifyTags(add, remove []string) error { + return m.db.MsgModifyTags(m.key, add, remove) +} + +func (m *Message) Remove(curDir maildir.Dir, mfs types.MultiFileStrategy) error { + rm, del, err := m.filenamesForStrategy(mfs, curDir) + if err != nil { + return err + } + + rm = append(rm, del...) + return m.deleteFiles(rm) +} + +func (m *Message) Copy(curDir, destDir maildir.Dir, mfs types.MultiFileStrategy) error { + cp, del, err := m.filenamesForStrategy(mfs, curDir) + if err != nil { + return err + } + + for _, filename := range cp { + source, key := parseFilename(filename) + if key == "" { + return fmt.Errorf("failed to parse message filename: %s", filename) + } + + newKey, err := source.Copy(destDir, key) + if err != nil { + return err + } + newFilename, err := destDir.Filename(newKey) + if err != nil { + return err + } + _, err = m.db.IndexFile(newFilename) + if err != nil { + return err + } + } + + return m.deleteFiles(del) +} + +func (m *Message) Move(curDir, destDir maildir.Dir, mfs types.MultiFileStrategy) error { + move, del, err := m.filenamesForStrategy(mfs, curDir) + if err != nil { + return err + } + + for _, filename := range move { + // Remove encoded UID information from the key to prevent sync issues + name := lib.StripUIDFromMessageFilename(filepath.Base(filename)) + dest := filepath.Join(string(destDir), "cur", name) + + if err := os.Rename(filename, dest); err != nil { + return err + } + + if _, err = m.db.IndexFile(dest); err != nil { + return err + } + + if err := m.db.DeleteMessage(filename); err != nil { + return err + } + } + + return m.deleteFiles(del) +} + +func (m *Message) deleteFiles(filenames []string) error { + for _, filename := range filenames { + if err := os.Remove(filename); err != nil { + return err + } + + if err := m.db.DeleteMessage(filename); err != nil { + return err + } + } + + return nil +} + +func (m *Message) filenamesForStrategy(strategy types.MultiFileStrategy, + curDir maildir.Dir, +) (act, del []string, err error) { + filenames, err := m.db.MsgFilenames(m.key) + if err != nil { + return nil, nil, err + } + return filterForStrategy(filenames, strategy, curDir) +} + +func filterForStrategy(filenames []string, strategy types.MultiFileStrategy, + curDir maildir.Dir, +) (act, del []string, err error) { + if curDir == "" && + (strategy == types.ActDir || strategy == types.ActDirDelRest) { + strategy = types.Refuse + } + + if len(filenames) < 2 { + return filenames, []string{}, nil + } + + act = []string{} + rest := []string{} + switch strategy { + case types.Refuse: + return nil, nil, fmt.Errorf("refusing to act on multiple files") + case types.ActAll: + act = filenames + case types.ActOne: + fallthrough + case types.ActOneDelRest: + act = filenames[:1] + rest = filenames[1:] + case types.ActDir: + fallthrough + case types.ActDirDelRest: + for _, filename := range filenames { + if filepath.Dir(filepath.Dir(filename)) == string(curDir) { + act = append(act, filename) + } else { + rest = append(rest, filename) + } + } + default: + return nil, nil, fmt.Errorf("invalid multi-file strategy %v", strategy) + } + + switch strategy { + case types.ActOneDelRest: + fallthrough + case types.ActDirDelRest: + del = rest + default: + del = []string{} + } + + return act, del, nil +} + +func parseFilename(filename string) (maildir.Dir, string) { + base := filepath.Base(filename) + dir := filepath.Dir(filename) + dir, curdir := filepath.Split(dir) + if curdir != "cur" { + return "", "" + } + split := strings.Split(base, ":") + if len(split) < 2 { + return maildir.Dir(dir), "" + } + key := split[0] + return maildir.Dir(dir), key +} diff --git a/worker/notmuch/message_test.go b/worker/notmuch/message_test.go new file mode 100644 index 0000000..51fcdb0 --- /dev/null +++ b/worker/notmuch/message_test.go @@ -0,0 +1,264 @@ +//go:build notmuch +// +build notmuch + +package notmuch + +import ( + "testing" + + "git.sr.ht/~rjarry/aerc/worker/types" + "github.com/emersion/go-maildir" +) + +func TestFilterForStrategy(t *testing.T) { + tests := []struct { + filenames []string + strategy types.MultiFileStrategy + curDir string + expectedAct []string + expectedDel []string + expectedErr bool + }{ + // if there's only one file, always act on it + { + filenames: []string{"/h/j/m/A/cur/a.b.c:2,"}, + strategy: types.Refuse, + curDir: "/h/j/m/B", + expectedAct: []string{"/h/j/m/A/cur/a.b.c:2,"}, + expectedDel: []string{}, + }, + { + filenames: []string{"/h/j/m/A/cur/a.b.c:2,"}, + strategy: types.ActAll, + curDir: "/h/j/m/B", + expectedAct: []string{"/h/j/m/A/cur/a.b.c:2,"}, + expectedDel: []string{}, + }, + { + filenames: []string{"/h/j/m/A/cur/a.b.c:2,"}, + strategy: types.ActOne, + curDir: "/h/j/m/B", + expectedAct: []string{"/h/j/m/A/cur/a.b.c:2,"}, + expectedDel: []string{}, + }, + { + filenames: []string{"/h/j/m/A/cur/a.b.c:2,"}, + strategy: types.ActOneDelRest, + curDir: "/h/j/m/B", + expectedAct: []string{"/h/j/m/A/cur/a.b.c:2,"}, + expectedDel: []string{}, + }, + { + filenames: []string{"/h/j/m/A/cur/a.b.c:2,"}, + strategy: types.ActDir, + curDir: "/h/j/m/B", + expectedAct: []string{"/h/j/m/A/cur/a.b.c:2,"}, + expectedDel: []string{}, + }, + { + filenames: []string{"/h/j/m/A/cur/a.b.c:2,"}, + strategy: types.ActDirDelRest, + curDir: "/h/j/m/B", + expectedAct: []string{"/h/j/m/A/cur/a.b.c:2,"}, + expectedDel: []string{}, + }, + + // follow strategy for multiple files + { + filenames: []string{ + "/h/j/m/A/cur/a.b.c:2,", + "/h/j/m/B/new/b.c.d", + "/h/j/m/B/cur/c.d.e:2,S", + "/h/j/m/C/new/d.e.f", + }, + strategy: types.Refuse, + curDir: "/h/j/m/B", + expectedErr: true, + }, + { + filenames: []string{ + "/h/j/m/A/cur/a.b.c:2,", + "/h/j/m/B/new/b.c.d", + "/h/j/m/B/cur/c.d.e:2,S", + "/h/j/m/C/new/d.e.f", + }, + strategy: types.ActAll, + curDir: "/h/j/m/B", + expectedAct: []string{ + "/h/j/m/A/cur/a.b.c:2,", + "/h/j/m/B/new/b.c.d", + "/h/j/m/B/cur/c.d.e:2,S", + "/h/j/m/C/new/d.e.f", + }, + expectedDel: []string{}, + }, + { + filenames: []string{ + "/h/j/m/A/cur/a.b.c:2,", + "/h/j/m/B/new/b.c.d", + "/h/j/m/B/cur/c.d.e:2,S", + "/h/j/m/C/new/d.e.f", + }, + strategy: types.ActOne, + curDir: "/h/j/m/B", + expectedAct: []string{"/h/j/m/A/cur/a.b.c:2,"}, + expectedDel: []string{}, + }, + { + filenames: []string{ + "/h/j/m/A/cur/a.b.c:2,", + "/h/j/m/B/new/b.c.d", + "/h/j/m/B/cur/c.d.e:2,S", + "/h/j/m/C/new/d.e.f", + }, + strategy: types.ActOneDelRest, + curDir: "/h/j/m/B", + expectedAct: []string{"/h/j/m/A/cur/a.b.c:2,"}, + expectedDel: []string{ + "/h/j/m/B/new/b.c.d", + "/h/j/m/B/cur/c.d.e:2,S", + "/h/j/m/C/new/d.e.f", + }, + }, + { + filenames: []string{ + "/h/j/m/A/cur/a.b.c:2,", + "/h/j/m/B/new/b.c.d", + "/h/j/m/B/cur/c.d.e:2,S", + "/h/j/m/C/new/d.e.f", + }, + strategy: types.ActDir, + curDir: "/h/j/m/B", + expectedAct: []string{ + "/h/j/m/B/new/b.c.d", + "/h/j/m/B/cur/c.d.e:2,S", + }, + expectedDel: []string{}, + }, + { + filenames: []string{ + "/h/j/m/A/cur/a.b.c:2,", + "/h/j/m/B/new/b.c.d", + "/h/j/m/B/cur/c.d.e:2,S", + "/h/j/m/C/new/d.e.f", + }, + strategy: types.ActDirDelRest, + curDir: "/h/j/m/B", + expectedAct: []string{ + "/h/j/m/B/new/b.c.d", + "/h/j/m/B/cur/c.d.e:2,S", + }, + expectedDel: []string{ + "/h/j/m/A/cur/a.b.c:2,", + "/h/j/m/C/new/d.e.f", + }, + }, + + // refuse to act on multiple files for ActDir and friends if + // no current dir is provided + { + filenames: []string{ + "/h/j/m/A/cur/a.b.c:2,", + "/h/j/m/B/new/b.c.d", + "/h/j/m/B/cur/c.d.e:2,S", + "/h/j/m/C/new/d.e.f", + }, + strategy: types.ActDir, + curDir: "", + expectedErr: true, + }, + { + filenames: []string{ + "/h/j/m/A/cur/a.b.c:2,", + "/h/j/m/B/new/b.c.d", + "/h/j/m/B/cur/c.d.e:2,S", + "/h/j/m/C/new/d.e.f", + }, + strategy: types.ActDirDelRest, + curDir: "", + expectedErr: true, + }, + + // act on multiple files w/o current dir for other strategies + { + filenames: []string{ + "/h/j/m/A/cur/a.b.c:2,", + "/h/j/m/B/new/b.c.d", + "/h/j/m/B/cur/c.d.e:2,S", + "/h/j/m/C/new/d.e.f", + }, + strategy: types.ActAll, + curDir: "", + expectedAct: []string{ + "/h/j/m/A/cur/a.b.c:2,", + "/h/j/m/B/new/b.c.d", + "/h/j/m/B/cur/c.d.e:2,S", + "/h/j/m/C/new/d.e.f", + }, + expectedDel: []string{}, + }, + { + filenames: []string{ + "/h/j/m/A/cur/a.b.c:2,", + "/h/j/m/B/new/b.c.d", + "/h/j/m/B/cur/c.d.e:2,S", + "/h/j/m/C/new/d.e.f", + }, + strategy: types.ActOne, + curDir: "", + expectedAct: []string{"/h/j/m/A/cur/a.b.c:2,"}, + expectedDel: []string{}, + }, + { + filenames: []string{ + "/h/j/m/A/cur/a.b.c:2,", + "/h/j/m/B/new/b.c.d", + "/h/j/m/B/cur/c.d.e:2,S", + "/h/j/m/C/new/d.e.f", + }, + strategy: types.ActOneDelRest, + curDir: "", + expectedAct: []string{"/h/j/m/A/cur/a.b.c:2,"}, + expectedDel: []string{ + "/h/j/m/B/new/b.c.d", + "/h/j/m/B/cur/c.d.e:2,S", + "/h/j/m/C/new/d.e.f", + }, + }, + } + + for i, test := range tests { + act, del, err := filterForStrategy(test.filenames, test.strategy, + maildir.Dir(test.curDir)) + + if test.expectedErr && err == nil { + t.Errorf("[test %d] got nil, expected error", i) + } + + if !test.expectedErr && err != nil { + t.Errorf("[test %d] got %v, expected nil", i, err) + } + + if !arrEq(act, test.expectedAct) { + t.Errorf("[test %d] got %v, expected %v", i, act, test.expectedAct) + } + + if !arrEq(del, test.expectedDel) { + t.Errorf("[test %d] got %v, expected %v", i, del, test.expectedDel) + } + } +} + +func arrEq(a, b []string) bool { + if len(a) != len(b) { + return false + } + + for i := range a { + if a[i] != b[i] { + return false + } + } + + return true +} diff --git a/worker/notmuch/notmuch.go b/worker/notmuch/notmuch.go new file mode 100644 index 0000000..854abb6 --- /dev/null +++ b/worker/notmuch/notmuch.go @@ -0,0 +1,30 @@ +//go:build notmuch +// +build notmuch + +package notmuch + +import "git.sr.ht/~rjarry/aerc/models" + +var tagToFlag = map[string]models.Flags{ + "unread": models.SeenFlag, + "replied": models.AnsweredFlag, + "passed": models.ForwardedFlag, + "draft": models.DraftFlag, + "flagged": models.FlaggedFlag, +} + +var flagToTag = map[models.Flags]string{ + models.SeenFlag: "unread", + models.AnsweredFlag: "replied", + models.ForwardedFlag: "passed", + models.DraftFlag: "draft", + models.FlaggedFlag: "flagged", +} + +var flagToInvert = map[models.Flags]bool{ + models.SeenFlag: true, + models.AnsweredFlag: false, + models.ForwardedFlag: false, + models.DraftFlag: false, + models.FlaggedFlag: false, +} diff --git a/worker/notmuch/search.go b/worker/notmuch/search.go new file mode 100644 index 0000000..ddbb13c --- /dev/null +++ b/worker/notmuch/search.go @@ -0,0 +1,111 @@ +//go:build notmuch +// +build notmuch + +package notmuch + +import ( + "fmt" + "strings" + + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/types" + "git.sr.ht/~rjarry/go-opt/v2" +) + +type queryBuilder struct { + s string +} + +func (q *queryBuilder) and(s string) { + if len(s) == 0 { + return + } + if len(q.s) != 0 { + q.s += " and " + } + q.s += "(" + s + ")" +} + +func (q *queryBuilder) or(s string) { + if len(s) == 0 { + return + } + if len(q.s) != 0 { + q.s += " or " + } + q.s += "(" + s + ")" +} + +func translate(crit *types.SearchCriteria) string { + if crit == nil { + return "" + } + var base queryBuilder + + // recipients + var from queryBuilder + for _, f := range crit.From { + from.or("from:" + opt.QuoteArg(f)) + } + if from.s != "" { + base.and(from.s) + } + + var to queryBuilder + for _, t := range crit.To { + to.or("to:" + opt.QuoteArg(t)) + } + if to.s != "" { + base.and(to.s) + } + + var cc queryBuilder + for _, c := range crit.Cc { + cc.or("cc:" + opt.QuoteArg(c)) + } + if cc.s != "" { + base.and(cc.s) + } + + // flags + for f := range flagToTag { + if crit.WithFlags.Has(f) { + base.and(getParsedFlag(f, false)) + } + if crit.WithoutFlags.Has(f) { + base.and(getParsedFlag(f, true)) + } + } + + // dates + switch { + case !crit.StartDate.IsZero() && !crit.EndDate.IsZero(): + base.and(fmt.Sprintf("date:@%d..@%d", + crit.StartDate.Unix(), crit.EndDate.Unix())) + case !crit.StartDate.IsZero(): + base.and(fmt.Sprintf("date:@%d..", crit.StartDate.Unix())) + case !crit.EndDate.IsZero(): + base.and(fmt.Sprintf("date:..@%d", crit.EndDate.Unix())) + } + + // other terms + if len(crit.Terms) > 0 { + if crit.SearchBody { + base.and("body:" + opt.QuoteArg(strings.Join(crit.Terms, " "))) + } else { + for _, term := range crit.Terms { + base.and(term) + } + } + } + + return base.s +} + +func getParsedFlag(flag models.Flags, inverse bool) string { + name := "tag:" + flagToTag[flag] + if flagToInvert[flag] { + name = "not " + name + } + return name +} diff --git a/worker/notmuch/worker.go b/worker/notmuch/worker.go new file mode 100644 index 0000000..8c954a6 --- /dev/null +++ b/worker/notmuch/worker.go @@ -0,0 +1,1049 @@ +//go:build notmuch +// +build notmuch + +package notmuch + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/url" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" + + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/watchers" + "git.sr.ht/~rjarry/aerc/lib/xdg" + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/handlers" + "git.sr.ht/~rjarry/aerc/worker/lib" + notmuch "git.sr.ht/~rjarry/aerc/worker/notmuch/lib" + "git.sr.ht/~rjarry/aerc/worker/types" + "github.com/emersion/go-maildir" +) + +func init() { + handlers.RegisterWorkerFactory("notmuch", NewWorker) +} + +var errUnsupported = fmt.Errorf("unsupported command") + +type worker struct { + w *types.Worker + nmStateChange chan bool + query string + currentQueryName string + queryMapOrder []string + nameQueryMap map[string]string + dynamicNameQueryMap map[string]string + store *lib.MaildirStore + maildirAccountPath string + db *notmuch.DB + setupErr error + currentSortCriteria []*types.SortCriterion + watcher watchers.FSWatcher + watcherDebounce *time.Timer + capabilities *models.Capabilities + headers []string + headersExclude []string + state uint64 + mfs types.MultiFileStrategy +} + +// NewWorker creates a new notmuch worker with the provided worker. +func NewWorker(w *types.Worker) (types.Backend, error) { + events := make(chan bool, 20) + watcher, err := watchers.NewWatcher() + if err != nil { + return nil, fmt.Errorf("could not create file system watcher: %w", err) + } + return &worker{ + w: w, + nmStateChange: events, + watcher: watcher, + capabilities: &models.Capabilities{ + Sort: true, + Thread: true, + }, + dynamicNameQueryMap: make(map[string]string), + }, nil +} + +// Run starts the worker's message handling loop. +func (w *worker) Run() { + for { + select { + case action := <-w.w.Actions(): + msg := w.w.ProcessAction(action) + err := w.handleMessage(msg) + switch { + case errors.Is(err, errUnsupported): + w.w.PostMessage(&types.Unsupported{ + Message: types.RespondTo(msg), + }, nil) + w.w.Errorf("ProcessAction(%T) unsupported: %v", msg, err) + case errors.Is(err, context.Canceled): + w.w.PostMessage(&types.Cancelled{ + Message: types.RespondTo(msg), + }, nil) + case err != nil: + w.w.PostMessage(&types.Error{ + Message: types.RespondTo(msg), + Error: err, + }, nil) + w.w.Errorf("ProcessAction(%T) failure: %v", msg, err) + } + case <-w.nmStateChange: + err := w.handleNotmuchEvent() + if err != nil { + w.w.Errorf("notmuch event failure: %v", err) + } + case <-w.watcher.Events(): + if w.watcherDebounce != nil { + w.watcherDebounce.Stop() + } + // Debounce FS changes + w.watcherDebounce = time.AfterFunc(50*time.Millisecond, func() { + defer log.PanicHandler() + w.nmStateChange <- true + }) + } + } +} + +func (w *worker) Capabilities() *models.Capabilities { + return w.capabilities +} + +func (w *worker) PathSeparator() string { + // make it configurable? + // <rockorager> You can use those in query maps to force a tree + // <rockorager> Might be nice to be configurable? I see some notmuch people namespace with "::" + return "/" +} + +func (w *worker) done(msg types.WorkerMessage) { + w.w.PostMessage(&types.Done{Message: types.RespondTo(msg)}, nil) +} + +func (w *worker) err(msg types.WorkerMessage, err error) { + w.w.PostMessage(&types.Error{ + Message: types.RespondTo(msg), + Error: err, + }, nil) +} + +func (w *worker) handleMessage(msg types.WorkerMessage) error { + if w.setupErr != nil { + // only configure can recover from a config error, bail for everything else + _, isConfigure := msg.(*types.Configure) + if !isConfigure { + return w.setupErr + } + } + if w.db != nil { + err := w.db.Connect() + if err != nil { + return err + } + defer w.db.Close() + } + + switch msg := msg.(type) { + case *types.Unsupported: + // No-op + case *types.Configure: + return w.handleConfigure(msg) + case *types.Connect: + return w.handleConnect(msg) + case *types.ListDirectories: + return w.handleListDirectories(msg) + case *types.OpenDirectory: + return w.handleOpenDirectory(msg) + case *types.FetchDirectoryContents: + return w.handleFetchDirectoryContents(msg) + case *types.FetchDirectoryThreaded: + return w.handleFetchDirectoryThreaded(msg) + case *types.FetchMessageHeaders: + return w.handleFetchMessageHeaders(msg) + case *types.FetchMessageBodyPart: + return w.handleFetchMessageBodyPart(msg) + case *types.FetchFullMessages: + return w.handleFetchFullMessages(msg) + case *types.FlagMessages: + return w.handleFlagMessages(msg) + case *types.AnsweredMessages: + return w.handleAnsweredMessages(msg) + case *types.ForwardedMessages: + return w.handleForwardedMessages(msg) + case *types.SearchDirectory: + return w.handleSearchDirectory(msg) + case *types.ModifyLabels: + return w.handleModifyLabels(msg) + case *types.CheckMail: + go w.handleCheckMail(msg) + return nil + case *types.DeleteMessages: + return w.handleDeleteMessages(msg) + case *types.CopyMessages: + return w.handleCopyMessages(msg) + case *types.MoveMessages: + return w.handleMoveMessages(msg) + case *types.AppendMessage: + return w.handleAppendMessage(msg) + case *types.CreateDirectory: + return w.handleCreateDirectory(msg) + case *types.RemoveDirectory: + return w.handleRemoveDirectory(msg) + } + return errUnsupported +} + +func (w *worker) handleConfigure(msg *types.Configure) error { + var err error + defer func() { + if err == nil { + w.setupErr = nil + return + } + w.setupErr = fmt.Errorf("notmuch: %w", err) + }() + + u, err := url.Parse(msg.Config.Source) + if err != nil { + w.w.Errorf("error configuring notmuch worker: %v", err) + return err + } + home := xdg.ExpandHome(u.Hostname()) + pathToDB := filepath.Join(home, u.Path) + err = w.loadQueryMap(msg.Config) + if err != nil { + return fmt.Errorf("could not load query map configuration: %w", err) + } + excludedTags := w.loadExcludeTags(msg.Config) + w.db = notmuch.NewDB(pathToDB, excludedTags) + + val, ok := msg.Config.Params["maildir-store"] + if ok { + path := xdg.ExpandHome(val) + w.maildirAccountPath = msg.Config.Params["maildir-account-path"] + + path = filepath.Join(path, w.maildirAccountPath) + store, err := lib.NewMaildirStore(path, false) + if err != nil { + return fmt.Errorf("Cannot initialize maildir store: %w", err) + } + w.store = store + } + w.headers = msg.Config.Headers + w.headersExclude = msg.Config.HeadersExclude + + mfs := msg.Config.Params["multi-file-strategy"] + if mfs != "" { + w.mfs, ok = types.StrToStrategy[mfs] + if !ok { + return fmt.Errorf("invalid multi-file strategy %s", mfs) + } + } else { + w.mfs = types.Refuse + } + + return nil +} + +func (w *worker) handleConnect(msg *types.Connect) error { + w.done(msg) + w.emitLabelList() + // Get initial db state + w.state = w.db.State() + // Watch all the files in the xapian folder for changes. We'll debounce + // changes, so catching multiple is ok + var dbPath string + path := filepath.Join(w.db.Path(), ".notmuch", "xapian") + _, err := os.Stat(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + dbPath = filepath.Join(w.db.Path(), "xapian") + } else { + return fmt.Errorf("error locating notmuch db: %w", err) + } + } else { + dbPath = path + } + + err = w.watcher.Configure(dbPath) + log.Tracef("Configuring watcher for path: %v", dbPath) + if err != nil { + return fmt.Errorf("error configuring watcher: %w", err) + } + return nil +} + +func (w *worker) handleListDirectories(msg *types.ListDirectories) error { + if w.store != nil { + folders, err := w.store.FolderMap() + if err != nil { + w.w.Errorf("failed listing directories: %v", err) + return err + } + for name := range folders { + w.w.PostMessage(&types.Directory{ + Message: types.RespondTo(msg), + Dir: &models.Directory{ + Name: name, + }, + }, nil) + } + } + + for _, name := range w.queryMapOrder { + w.w.PostMessage(&types.Directory{ + Message: types.RespondTo(msg), + Dir: &models.Directory{ + Name: name, + Role: models.QueryRole, + }, + }, nil) + } + + for name := range w.dynamicNameQueryMap { + w.w.PostMessage(&types.Directory{ + Message: types.RespondTo(msg), + Dir: &models.Directory{ + Name: name, + Role: models.QueryRole, + }, + }, nil) + } + + // Update dir counts when listing directories + err := w.updateDirCounts() + if err != nil { + return err + } + w.done(msg) + return nil +} + +func (w *worker) getDirectoryInfo(name string, query string) *models.DirectoryInfo { + dirInfo := &models.DirectoryInfo{ + Name: name, + // total messages + Exists: 0, + // new messages since mailbox was last opened + Recent: 0, + // total unread + Unseen: 0, + } + + count, err := w.db.QueryCountMessages(query) + if err != nil { + return dirInfo + } + dirInfo.Exists = count.Exists + dirInfo.Unseen = count.Unread + + return dirInfo +} + +func (w *worker) handleOpenDirectory(msg *types.OpenDirectory) error { + if msg.Context.Err() != nil { + return context.Canceled + } + w.w.Tracef("opening %s with query %s", msg.Directory, msg.Query) + + var exists bool + q := "" + if w.store != nil { + folders, _ := w.store.FolderMap() + var dir maildir.Dir + dir, exists = folders[msg.Directory] + if exists { + folder := filepath.Join(w.maildirAccountPath, msg.Directory) + q = fmt.Sprintf("folder:%s", strconv.Quote(folder)) + if err := w.processNewMaildirFiles(string(dir)); err != nil { + return err + } + } + } + if q == "" { + q, exists = w.nameQueryMap[msg.Directory] + if !exists { + q, exists = w.dynamicNameQueryMap[msg.Directory] + } + } + if !exists || msg.Force { + q = msg.Query + if q == "" { + q = msg.Directory + } + w.dynamicNameQueryMap[msg.Directory] = q + w.w.PostMessage(&types.Directory{ + Message: types.RespondTo(msg), + Dir: &models.Directory{ + Name: msg.Directory, + Role: models.QueryRole, + }, + }, nil) + } else if msg.Query != "" && msg.Query != q { + return errors.New("cannot use existing folder name for new query") + } + w.query = q + w.currentQueryName = msg.Directory + + w.w.PostMessage(&types.DirectoryInfo{ + Info: w.getDirectoryInfo(msg.Directory, w.query), + Message: types.RespondTo(msg), + }, nil) + if !exists { + w.w.PostMessage(&types.DirectoryInfo{ + Info: w.getDirectoryInfo(msg.Directory, w.query), + Message: types.RespondTo(msg), + }, nil) + } + w.done(msg) + return nil +} + +func (w *worker) handleFetchDirectoryContents( + msg *types.FetchDirectoryContents, +) error { + w.currentSortCriteria = msg.SortCriteria + err := w.emitDirectoryContents(msg) + if err != nil { + return err + } + w.done(msg) + return nil +} + +func (w *worker) handleFetchDirectoryThreaded( + msg *types.FetchDirectoryThreaded, +) error { + // w.currentSortCriteria = msg.SortCriteria + err := w.emitDirectoryThreaded(msg) + if err != nil { + return err + } + w.done(msg) + return nil +} + +func (w *worker) handleFetchMessageHeaders( + msg *types.FetchMessageHeaders, +) error { + for _, uid := range msg.Uids { + m, err := w.msgFromUid(uid) + if err != nil { + w.w.Errorf("could not get message: %v", err) + w.emitMessageInfoError(msg, uid, err) + continue + } + err = w.emitMessageInfo(m, msg) + if err != nil { + w.w.Errorf("could not emit message info: %v", err) + w.emitMessageInfoError(msg, uid, err) + continue + } + } + w.done(msg) + return nil +} + +func (w *worker) uidsFromQuery(ctx context.Context, query string) ([]models.UID, error) { + msgIDs, err := w.db.MsgIDsFromQuery(ctx, query) + if err != nil { + return nil, err + } + var uids []models.UID + for _, id := range msgIDs { + uids = append(uids, models.UID(id)) + } + return uids, nil +} + +func (w *worker) msgFromUid(uid models.UID) (*Message, error) { + msg := &Message{ + key: string(uid), + uid: uid, + db: w.db, + } + return msg, nil +} + +func (w *worker) handleFetchMessageBodyPart( + msg *types.FetchMessageBodyPart, +) error { + m, err := w.msgFromUid(msg.Uid) + if err != nil { + w.w.Errorf("could not get message %d: %v", msg.Uid, err) + return err + } + r, err := m.NewBodyPartReader(msg.Part) + if err != nil { + w.w.Errorf( + "could not get body part reader for message=%d, parts=%#v: %w", + msg.Uid, msg.Part, err) + return err + } + w.w.PostMessage(&types.MessageBodyPart{ + Message: types.RespondTo(msg), + Part: &models.MessageBodyPart{ + Reader: r, + Uid: msg.Uid, + }, + }, nil) + + w.done(msg) + return nil +} + +func (w *worker) handleFetchFullMessages(msg *types.FetchFullMessages) error { + for _, uid := range msg.Uids { + m, err := w.msgFromUid(uid) + if err != nil { + w.w.Errorf("could not get message %d: %v", uid, err) + return err + } + r, err := m.NewReader() + if err != nil { + w.w.Errorf("could not get message reader: %v", err) + return err + } + defer r.Close() + b, err := io.ReadAll(r) + if err != nil { + return err + } + w.w.PostMessage(&types.FullMessage{ + Message: types.RespondTo(msg), + Content: &models.FullMessage{ + Uid: uid, + Reader: bytes.NewReader(b), + }, + }, nil) + } + w.done(msg) + return nil +} + +func (w *worker) handleAnsweredMessages(msg *types.AnsweredMessages) error { + for _, uid := range msg.Uids { + m, err := w.msgFromUid(uid) + if err != nil { + w.w.Errorf("could not get message: %v", err) + w.err(msg, err) + continue + } + if err := m.MarkAnswered(msg.Answered); err != nil { + w.w.Errorf("could not mark message as answered: %v", err) + w.err(msg, err) + continue + } + } + w.done(msg) + return nil +} + +func (w *worker) handleForwardedMessages(msg *types.ForwardedMessages) error { + for _, uid := range msg.Uids { + m, err := w.msgFromUid(uid) + if err != nil { + w.w.Errorf("could not get message: %v", err) + w.err(msg, err) + continue + } + if err := m.MarkForwarded(msg.Forwarded); err != nil { + w.w.Errorf("could not mark message as forwarded: %v", err) + w.err(msg, err) + continue + } + } + w.done(msg) + return nil +} + +func (w *worker) handleFlagMessages(msg *types.FlagMessages) error { + for _, uid := range msg.Uids { + m, err := w.msgFromUid(uid) + if err != nil { + w.w.Errorf("could not get message: %v", err) + w.err(msg, err) + continue + } + if err := m.SetFlag(msg.Flags, msg.Enable); err != nil { + w.w.Errorf("could not set flag %v as %t for message: %v", + msg.Flags, msg.Enable, err) + w.err(msg, err) + continue + } + } + w.done(msg) + return nil +} + +func (w *worker) handleSearchDirectory(msg *types.SearchDirectory) error { + search := notmuch.AndQueries(w.query, translate(msg.Criteria)) + log.Debugf("search query: '%s'", search) + uids, err := w.uidsFromQuery(msg.Context, search) + if err != nil { + return err + } + w.w.PostMessage(&types.SearchResults{ + Message: types.RespondTo(msg), + Uids: uids, + }, nil) + return nil +} + +func (w *worker) handleModifyLabels(msg *types.ModifyLabels) error { + for _, uid := range msg.Uids { + m, err := w.msgFromUid(uid) + if err != nil { + return fmt.Errorf("could not get message from uid %s: %w", uid, err) + } + err = m.ModifyTags(msg.Add, msg.Remove) + if err != nil { + return fmt.Errorf("could not modify message tags: %w", err) + } + } + w.done(msg) + return nil +} + +func (w *worker) loadQueryMap(acctConfig *config.AccountConfig) error { + raw, ok := acctConfig.Params["query-map"] + if !ok { + // nothing to do + return nil + } + file := xdg.ExpandHome(raw) + f, err := os.Open(file) + if err != nil { + return err + } + defer f.Close() + w.nameQueryMap, w.queryMapOrder, err = lib.ParseFolderMap(f) + return err +} + +func (w *worker) loadExcludeTags( + acctConfig *config.AccountConfig, +) []string { + raw, ok := acctConfig.Params["exclude-tags"] + if !ok { + // nothing to do + return nil + } + excludedTags := strings.Split(raw, ",") + for idx, tag := range excludedTags { + excludedTags[idx] = strings.Trim(tag, " ") + } + return excludedTags +} + +func (w *worker) emitDirectoryContents(parent types.WorkerMessage) error { + query := w.query + ctx := context.Background() + if msg, ok := parent.(*types.FetchDirectoryContents); ok { + query = notmuch.AndQueries(query, translate(msg.Filter)) + log.Debugf("filter query: '%s'", query) + ctx = msg.Context + } + uids, err := w.uidsFromQuery(ctx, query) + if err != nil { + return fmt.Errorf("could not fetch uids: %w", err) + } + sortedUids, err := w.sort(uids, w.currentSortCriteria) + if err != nil { + w.w.Errorf("error sorting directory: %v", err) + return err + } + w.w.PostMessage(&types.DirectoryContents{ + Message: types.RespondTo(parent), + Uids: sortedUids, + }, nil) + return nil +} + +func (w *worker) emitDirectoryThreaded(parent types.WorkerMessage) error { + query := w.query + ctx := context.Background() + threadContext := false + if msg, ok := parent.(*types.FetchDirectoryThreaded); ok { + query = notmuch.AndQueries(query, translate(msg.Filter)) + log.Debugf("filter query: '%s'", query) + ctx = msg.Context + threadContext = msg.ThreadContext + } + threads, err := w.db.ThreadsFromQuery(ctx, query, threadContext) + if err != nil { + return err + } + w.w.PostMessage(&types.DirectoryThreaded{ + Threads: threads, + }, nil) + return nil +} + +func (w *worker) emitMessageInfoError(msg types.WorkerMessage, uid models.UID, err error) { + w.w.PostMessage(&types.MessageInfo{ + Info: &models.MessageInfo{ + Envelope: &models.Envelope{}, + Flags: models.SeenFlag, + Uid: uid, + Error: err, + }, + Message: types.RespondTo(msg), + }, nil) +} + +func (w *worker) emitMessageInfo(m *Message, + parent types.WorkerMessage, +) error { + info, err := m.MessageInfo() + if err != nil { + return fmt.Errorf("could not get MessageInfo: %w", err) + } + switch { + case len(w.headersExclude) > 0: + info.RFC822Headers = lib.LimitHeaders(info.RFC822Headers, w.headersExclude, true) + case len(w.headers) > 0: + info.RFC822Headers = lib.LimitHeaders(info.RFC822Headers, w.headers, false) + } + switch parent { + case nil: + w.w.PostMessage(&types.MessageInfo{ + Info: info, + }, nil) + default: + w.w.PostMessage(&types.MessageInfo{ + Message: types.RespondTo(parent), + Info: info, + }, nil) + } + return nil +} + +func (w *worker) emitLabelList() { + tags := w.db.ListTags() + w.w.PostMessage(&types.LabelList{Labels: tags}, nil) +} + +func (w *worker) sort(uids []models.UID, + criteria []*types.SortCriterion, +) ([]models.UID, error) { + if len(criteria) == 0 { + return uids, nil + } + var msgInfos []*models.MessageInfo + for _, uid := range uids { + m, err := w.msgFromUid(uid) + if err != nil { + w.w.Errorf("could not get message: %v", err) + continue + } + info, err := m.MessageInfo() + if err != nil { + w.w.Errorf("could not get message info: %v", err) + continue + } + msgInfos = append(msgInfos, info) + } + sortedUids, err := lib.Sort(msgInfos, criteria) + if err != nil { + w.w.Errorf("could not sort the messages: %v", err) + return nil, err + } + return sortedUids, nil +} + +func (w *worker) handleCheckMail(msg *types.CheckMail) { + defer log.PanicHandler() + if msg.Command == "" { + w.err(msg, fmt.Errorf("(%s) checkmail: no command specified", msg.Account())) + return + } + ctx, cancel := context.WithTimeout(context.Background(), msg.Timeout) + defer cancel() + cmd := exec.CommandContext(ctx, "sh", "-c", msg.Command) + err := cmd.Run() + switch { + case ctx.Err() != nil: + w.err(msg, fmt.Errorf("(%s) checkmail: timed out", msg.Account())) + case err != nil: + w.err(msg, fmt.Errorf("(%s) checkmail: error running command: %w", msg.Account(), err)) + default: + w.done(msg) + } +} + +func (w *worker) handleDeleteMessages(msg *types.DeleteMessages) error { + if w.store == nil { + return errUnsupported + } + + var deleted []models.UID + + folders, _ := w.store.FolderMap() + curDir := folders[w.currentQueryName] + + mfs := w.mfs + if msg.MultiFileStrategy != nil { + mfs = *msg.MultiFileStrategy + } + + for _, uid := range msg.Uids { + m, err := w.msgFromUid(uid) + if err != nil { + w.w.Errorf("could not get message: %v", err) + w.err(msg, err) + continue + } + if err := m.Remove(curDir, mfs); err != nil { + w.w.Errorf("could not remove message: %v", err) + w.err(msg, err) + continue + } + deleted = append(deleted, uid) + } + if len(deleted) > 0 { + w.w.PostMessage(&types.MessagesDeleted{ + Message: types.RespondTo(msg), + Uids: deleted, + }, nil) + w.done(msg) + } + return nil +} + +func (w *worker) handleCopyMessages(msg *types.CopyMessages) error { + if w.store == nil { + return errUnsupported + } + + // Only allow file to be copied to a maildir folder + folders, _ := w.store.FolderMap() + dest, ok := folders[msg.Destination] + if !ok { + return fmt.Errorf("Can only copy file to a maildir folder") + } + + curDir := folders[w.currentQueryName] + + mfs := w.mfs + if msg.MultiFileStrategy != nil { + mfs = *msg.MultiFileStrategy + } + + for _, uid := range msg.Uids { + m, err := w.msgFromUid(uid) + if err != nil { + w.w.Errorf("could not get message: %v", err) + return err + } + if err := m.Copy(curDir, dest, mfs); err != nil { + w.w.Errorf("could not copy message: %v", err) + return err + } + } + w.w.PostMessage(&types.MessagesCopied{ + Message: types.RespondTo(msg), + Destination: msg.Destination, + Uids: msg.Uids, + }, nil) + w.done(msg) + return nil +} + +func (w *worker) handleMoveMessages(msg *types.MoveMessages) error { + if w.store == nil { + return errUnsupported + } + + var moved []models.UID + + folders, _ := w.store.FolderMap() + + // Only allow file to be moved to a maildir folder + dest, ok := folders[msg.Destination] + if !ok { + return fmt.Errorf("Can only move file to a maildir folder") + } + + curDir := folders[w.currentQueryName] + + mfs := w.mfs + if msg.MultiFileStrategy != nil { + mfs = *msg.MultiFileStrategy + } + + var err error + for _, uid := range msg.Uids { + m, err := w.msgFromUid(uid) + if err != nil { + w.w.Errorf("could not get message: %v", err) + break + } + if err := m.Move(curDir, dest, mfs); err != nil { + w.w.Errorf("could not move message: %v", err) + break + } + moved = append(moved, uid) + } + w.w.PostMessage(&types.MessagesDeleted{ + Message: types.RespondTo(msg), + Uids: moved, + }, nil) + if err == nil { + w.done(msg) + } + return err +} + +func (w *worker) handleAppendMessage(msg *types.AppendMessage) error { + if w.store == nil { + return errUnsupported + } + + // Only allow file to be created in a maildir folder + // since we are the "master" maildir process, we can modify the maildir directly + folders, _ := w.store.FolderMap() + dest, ok := folders[msg.Destination] + if !ok { + return fmt.Errorf("Can only create file in a maildir folder") + } + key, writer, err := dest.Create(lib.ToMaildirFlags(msg.Flags)) + if err != nil { + w.w.Errorf("could not create message at %s: %v", msg.Destination, err) + return err + } + filename, err := dest.Filename(key) + if err != nil { + writer.Close() + return err + } + if _, err := io.Copy(writer, msg.Reader); err != nil { + w.w.Errorf("could not write message to destination: %v", err) + writer.Close() + os.Remove(filename) + return err + } + writer.Close() + id, err := w.db.IndexFile(filename) + if err != nil { + return err + } + + err = w.addFlags(id, msg.Flags) + if err != nil { + return err + } + + w.w.PostMessage(&types.DirectoryInfo{ + Info: w.getDirectoryInfo(w.currentQueryName, w.query), + }, nil) + w.done(msg) + return nil +} + +func (w *worker) handleCreateDirectory(msg *types.CreateDirectory) error { + if w.store == nil { + return errUnsupported + } + + dir := w.store.Dir(msg.Directory) + if err := dir.Init(); err != nil { + w.w.Errorf("could not create directory %s: %v", + msg.Directory, err) + return err + } + w.done(msg) + return nil +} + +func (w *worker) handleRemoveDirectory(msg *types.RemoveDirectory) error { + _, inQueryMap := w.nameQueryMap[msg.Directory] + if inQueryMap { + return errUnsupported + } + + if _, ok := w.dynamicNameQueryMap[msg.Directory]; ok { + delete(w.dynamicNameQueryMap, msg.Directory) + w.done(msg) + return nil + } + + if w.store == nil { + w.done(msg) + return nil + } + + dir := w.store.Dir(msg.Directory) + if err := os.RemoveAll(string(dir)); err != nil { + w.w.Errorf("could not remove directory %s: %v", + msg.Directory, err) + return err + } + w.done(msg) + return nil +} + +// This is a hack that calls MsgModifyTags with an empty list of tags to +// apply on new messages causing notmuch to rename files and effectively +// move them into the cur/ dir. +func (w *worker) processNewMaildirFiles(dir string) error { + f, err := os.Open(filepath.Join(dir, "new")) + if err != nil { + return err + } + defer f.Close() + names, err := f.Readdirnames(0) + if err != nil { + return err + } + + for _, n := range names { + if n[0] == '.' { + continue + } + + key, err := w.db.MsgIDFromFilename(filepath.Join(dir, "new", n)) + if err != nil { + // Message is not yet indexed, leave it alone + continue + } + // Force message to move from new/ to cur/ + err = w.db.MsgModifyTags(key, nil, nil) + if err != nil { + w.w.Errorf("MsgModifyTags failed: %v", err) + } + } + + return nil +} + +func (w *worker) addFlags(id string, flags models.Flags) error { + addTags := []string{} + removeTags := []string{} + for flag, tag := range flagToTag { + if !flags.Has(flag) { + continue + } + + if flagToInvert[flag] { + removeTags = append(removeTags, tag) + } else { + addTags = append(addTags, tag) + } + } + + return w.db.MsgModifyTags(id, addTags, removeTags) +} diff --git a/worker/types/messages.go b/worker/types/messages.go new file mode 100644 index 0000000..0174b54 --- /dev/null +++ b/worker/types/messages.go @@ -0,0 +1,308 @@ +package types + +import ( + "context" + "io" + "time" + + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/models" + "github.com/emersion/go-message/mail" +) + +type WorkerMessage interface { + InResponseTo() WorkerMessage + getId() int64 + setId(id int64) + Account() string + setAccount(string) +} + +type Message struct { + inResponseTo WorkerMessage + id int64 + acct string +} + +func RespondTo(msg WorkerMessage) Message { + return Message{ + inResponseTo: msg, + } +} + +func (m Message) InResponseTo() WorkerMessage { + return m.inResponseTo +} + +func (m Message) getId() int64 { + return m.id +} + +func (m *Message) setId(id int64) { + m.id = id +} + +func (m *Message) Account() string { + return m.acct +} + +func (m *Message) setAccount(name string) { + m.acct = name +} + +// Meta-messages + +type Done struct { + Message +} + +type Error struct { + Message + Error error +} + +type Cancelled struct { + Message +} + +type ConnError struct { + Message + Error error +} + +type Unsupported struct { + Message +} + +// Actions + +type Configure struct { + Message + Config *config.AccountConfig +} + +type Connect struct { + Message +} + +type Reconnect struct { + Message +} + +type Disconnect struct { + Message +} + +type ListDirectories struct { + Message +} + +type OpenDirectory struct { + Message + Context context.Context + Directory string + Query string + Force bool +} + +type FetchDirectoryContents struct { + Message + Context context.Context + SortCriteria []*SortCriterion + Filter *SearchCriteria +} + +type FetchDirectoryThreaded struct { + Message + Context context.Context + SortCriteria []*SortCriterion + Filter *SearchCriteria + ThreadContext bool +} + +type SearchDirectory struct { + Message + Context context.Context + Criteria *SearchCriteria +} + +type DirectoryThreaded struct { + Message + Threads []*Thread +} + +type CreateDirectory struct { + Message + Directory string + Quiet bool +} + +type RemoveDirectory struct { + Message + Directory string + Quiet bool +} + +type FetchMessageHeaders struct { + Message + Context context.Context + Uids []models.UID +} + +type FetchFullMessages struct { + Message + Uids []models.UID +} + +type FetchMessageBodyPart struct { + Message + Uid models.UID + Part []int +} + +type FetchMessageFlags struct { + Message + Context context.Context + Uids []models.UID +} + +type DeleteMessages struct { + Message + Uids []models.UID + MultiFileStrategy *MultiFileStrategy +} + +// Flag messages with different mail types +type FlagMessages struct { + Message + Enable bool + Flags models.Flags + Uids []models.UID +} + +type AnsweredMessages struct { + Message + Answered bool + Uids []models.UID +} + +type ForwardedMessages struct { + Message + Forwarded bool + Uids []models.UID +} + +type CopyMessages struct { + Message + Destination string + Uids []models.UID + MultiFileStrategy *MultiFileStrategy +} + +type MoveMessages struct { + Message + Destination string + Uids []models.UID + MultiFileStrategy *MultiFileStrategy +} + +type AppendMessage struct { + Message + Destination string + Flags models.Flags + Date time.Time + Reader io.Reader + Length int +} + +type CheckMail struct { + Message + Directories []string + Command string + Timeout time.Duration +} + +type StartSendingMessage struct { + Message + From *mail.Address + Rcpts []*mail.Address + CopyTo []string +} + +// Messages + +type Directory struct { + Message + Dir *models.Directory +} + +type DirectoryInfo struct { + Message + Info *models.DirectoryInfo + Refetch bool +} + +type DirectoryContents struct { + Message + Uids []models.UID +} + +type SearchResults struct { + Message + Uids []models.UID +} + +type MessageInfo struct { + Message + Info *models.MessageInfo + NeedsFlags bool +} + +type FullMessage struct { + Message + Content *models.FullMessage +} + +type MessageBodyPart struct { + Message + Part *models.MessageBodyPart +} + +type MessagesDeleted struct { + Message + Uids []models.UID +} + +type MessagesCopied struct { + Message + Destination string + Uids []models.UID +} + +type MessagesMoved struct { + Message + Destination string + Uids []models.UID +} + +type ModifyLabels struct { + Message + Uids []models.UID + Add []string + Remove []string +} + +type LabelList struct { + Message + Labels []string +} + +type CheckMailDirectories struct { + Message + Directories []string +} + +type MessageWriter struct { + Message + Writer io.WriteCloser +} diff --git a/worker/types/mfs.go b/worker/types/mfs.go new file mode 100644 index 0000000..a842082 --- /dev/null +++ b/worker/types/mfs.go @@ -0,0 +1,33 @@ +package types + +// MultiFileStrategy represents a strategy for taking file-based actions (e.g., +// move, copy, delete) on messages that are represented by more than one file. +// These strategies are only used by the notmuch backend but are defined in this +// package to prevent import cycles. +type MultiFileStrategy uint + +const ( + Refuse MultiFileStrategy = iota + ActAll + ActOne + ActOneDelRest + ActDir + ActDirDelRest +) + +var StrToStrategy = map[string]MultiFileStrategy{ + "refuse": Refuse, + "act-all": ActAll, + "act-one": ActOne, + "act-one-delete-rest": ActOneDelRest, + "act-dir": ActDir, + "act-dir-delete-rest": ActDirDelRest, +} + +func StrategyStrs() []string { + strs := make([]string, 0, len(StrToStrategy)) + for s := range StrToStrategy { + strs = append(strs, s) + } + return strs +} diff --git a/worker/types/search.go b/worker/types/search.go new file mode 100644 index 0000000..441fae8 --- /dev/null +++ b/worker/types/search.go @@ -0,0 +1,84 @@ +package types + +import ( + "net/textproto" + "time" + + "git.sr.ht/~rjarry/aerc/models" +) + +type SearchCriteria struct { + WithFlags models.Flags + WithoutFlags models.Flags + From []string + To []string + Cc []string + Headers textproto.MIMEHeader + StartDate time.Time + EndDate time.Time + SearchBody bool + SearchAll bool + Terms []string + UseExtension bool +} + +func (c *SearchCriteria) PrepareHeader() { + if c == nil { + return + } + if c.Headers == nil { + c.Headers = make(textproto.MIMEHeader) + } + for _, from := range c.From { + c.Headers.Add("From", from) + } + for _, to := range c.To { + c.Headers.Add("To", to) + } + for _, cc := range c.Cc { + c.Headers.Add("Cc", cc) + } +} + +func (c *SearchCriteria) Combine(other *SearchCriteria) *SearchCriteria { + if c == nil { + return other + } + headers := make(textproto.MIMEHeader) + for k, v := range c.Headers { + headers[k] = v + } + for k, v := range other.Headers { + headers[k] = v + } + start := c.StartDate + if !other.StartDate.IsZero() { + start = other.StartDate + } + end := c.EndDate + if !other.EndDate.IsZero() { + end = other.EndDate + } + from := make([]string, len(c.From)+len(other.From)) + copy(from[:len(c.From)], c.From) + copy(from[len(c.From):], other.From) + to := make([]string, len(c.To)+len(other.To)) + copy(to[:len(c.To)], c.To) + copy(to[len(c.To):], other.To) + cc := make([]string, len(c.Cc)+len(other.Cc)) + copy(cc[:len(c.Cc)], c.Cc) + copy(cc[len(c.Cc):], other.Cc) + return &SearchCriteria{ + WithFlags: c.WithFlags | other.WithFlags, + WithoutFlags: c.WithoutFlags | other.WithoutFlags, + From: from, + To: to, + Cc: cc, + Headers: headers, + StartDate: start, + EndDate: end, + SearchBody: c.SearchBody || other.SearchBody, + SearchAll: c.SearchAll || other.SearchAll, + Terms: append(c.Terms, other.Terms...), + } +} diff --git a/worker/types/sort.go b/worker/types/sort.go new file mode 100644 index 0000000..39f99ab --- /dev/null +++ b/worker/types/sort.go @@ -0,0 +1,20 @@ +package types + +type SortField int + +const ( + SortArrival SortField = iota + SortCc + SortDate + SortFrom + SortRead + SortSize + SortSubject + SortTo + SortFlagged +) + +type SortCriterion struct { + Field SortField + Reverse bool +} diff --git a/worker/types/thread.go b/worker/types/thread.go new file mode 100644 index 0000000..fe6c56b --- /dev/null +++ b/worker/types/thread.go @@ -0,0 +1,185 @@ +package types + +import ( + "errors" + "fmt" + "sort" + + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/models" +) + +type Thread struct { + Uid models.UID + Parent *Thread + PrevSibling *Thread + NextSibling *Thread + FirstChild *Thread + + Hidden int // if this flag is not zero the message isn't rendered in the UI + Deleted bool // if this flag is set the message was deleted + + // if this flag is set the message is the root of an incomplete thread + Dummy bool + + // Context indicates the message doesn't match the mailbox / query but + // is displayed for context + Context bool +} + +// AddChild appends the child node at the end of the existing children of t. +func (t *Thread) AddChild(child *Thread) { + t.InsertCmp(child, func(_, _ *Thread) bool { return true }) +} + +// OrderedInsert inserts the child node in ascending order among the existing +// children based on their respective UIDs. +func (t *Thread) OrderedInsert(child *Thread) { + t.InsertCmp(child, func(child, iter *Thread) bool { return child.Uid > iter.Uid }) +} + +// InsertCmp inserts child as a child node into t in ascending order. The +// ascending order is determined by the bigger function that compares the child +// with the existing children. It should return true when the child is bigger +// than the other, and false otherwise. +func (t *Thread) InsertCmp(child *Thread, bigger func(*Thread, *Thread) bool) { + if t.FirstChild == nil { + t.FirstChild = child + } else { + start := &Thread{NextSibling: t.FirstChild} + var iter *Thread + for iter = start; iter.NextSibling != nil && + bigger(child, iter.NextSibling); iter = iter.NextSibling { + } + child.NextSibling = iter.NextSibling + iter.NextSibling = child + t.FirstChild = start.NextSibling + } + child.Parent = t +} + +func (t *Thread) Walk(walkFn NewThreadWalkFn) error { + err := newWalk(t, walkFn, 0, nil) + if errors.Is(err, ErrSkipThread) { + return nil + } + return err +} + +// Root returns the root thread of the thread tree +func (t *Thread) Root() *Thread { + if t == nil { + return nil + } + var iter *Thread + for iter = t; iter.Parent != nil; iter = iter.Parent { + } + return iter +} + +// Uids returns all associated uids for the given thread and its children +func (t *Thread) Uids() []models.UID { + if t == nil { + return nil + } + uids := make([]models.UID, 0) + err := t.Walk(func(node *Thread, _ int, _ error) error { + uids = append(uids, node.Uid) + return nil + }) + if err != nil { + log.Errorf("walk to collect uids failed: %v", err) + } + return uids +} + +func (t *Thread) String() string { + if t == nil { + return "<nil>" + } + var parent models.UID + if t.Parent != nil { + parent = t.Parent.Uid + } + var next models.UID + if t.NextSibling != nil { + next = t.NextSibling.Uid + } + var child models.UID + if t.FirstChild != nil { + child = t.FirstChild.Uid + } + return fmt.Sprintf( + "[%s] (parent:%s, next:%s, child:%s)", + t.Uid, parent, next, child, + ) +} + +func newWalk(node *Thread, walkFn NewThreadWalkFn, lvl int, ce error) error { + if node == nil { + return nil + } + err := walkFn(node, lvl, ce) + if err != nil { + return err + } + for child := node.FirstChild; child != nil; child = child.NextSibling { + err = newWalk(child, walkFn, lvl+1, err) + if errors.Is(err, ErrSkipThread) { + err = nil + continue + } else if err != nil { + return err + } + } + return nil +} + +var ErrSkipThread = errors.New("skip this Thread") + +type NewThreadWalkFn func(t *Thread, level int, currentErr error) error + +// Implement interface to be able to sort threads by newest (max UID) +type ByUID []*Thread + +func getMaxUID(thread *Thread) models.UID { + // TODO: should we make this part of the Thread type to avoid recomputation? + var Uid models.UID + + _ = thread.Walk(func(t *Thread, _ int, currentErr error) error { + if t.Deleted || t.Hidden > 0 { + return nil + } + if t.Uid > Uid { + Uid = t.Uid + } + return nil + }) + return Uid +} + +func (s ByUID) Len() int { + return len(s) +} + +func (s ByUID) Swap(i, j int) { + s[i], s[j] = s[j], s[i] +} + +func (s ByUID) Less(i, j int) bool { + maxUID_i := getMaxUID(s[i]) + maxUID_j := getMaxUID(s[j]) + return maxUID_i < maxUID_j +} + +func SortThreadsBy(toSort []*Thread, sortBy []models.UID) { + // build a map from sortBy + uidMap := make(map[models.UID]int) + for i, uid := range sortBy { + uidMap[uid] = i + } + // sortslice of toSort with less function of indexing the map sortBy + sort.Slice(toSort, func(i, j int) bool { + return uidMap[getMaxUID(toSort[i])] < uidMap[getMaxUID(toSort[j])] + }) +} diff --git a/worker/types/thread_test.go b/worker/types/thread_test.go new file mode 100644 index 0000000..a6a0f32 --- /dev/null +++ b/worker/types/thread_test.go @@ -0,0 +1,232 @@ +package types + +import ( + "fmt" + "strings" + "testing" + + "git.sr.ht/~rjarry/aerc/models" +) + +func genFakeTree() *Thread { + tree := new(Thread) + var prevChild *Thread + for i := uint32(1); i < uint32(3); i++ { + child := &Thread{ + Uid: models.Uint32ToUid(i * 10), + Parent: tree, + PrevSibling: prevChild, + } + if prevChild != nil { + prevChild.NextSibling = child + } else if tree.FirstChild == nil { + tree.FirstChild = child + } else { + panic("unreachable") + } + prevChild = child + var prevSecond *Thread + for j := uint32(1); j < uint32(3); j++ { + second := &Thread{ + Uid: models.Uint32ToUid(models.UidToUint32(child.Uid) + j), + Parent: child, + PrevSibling: prevSecond, + } + if prevSecond != nil { + prevSecond.NextSibling = second + } else if child.FirstChild == nil { + child.FirstChild = second + } else { + panic("unreachable") + } + prevSecond = second + var prevThird *Thread + limit := uint32(3) + if j == 2 { + limit = 8 + } + for k := uint32(1); k < limit; k++ { + third := &Thread{ + Uid: models.Uint32ToUid(models.UidToUint32(second.Uid)*10 + j), + Parent: second, + PrevSibling: prevThird, + } + if prevThird != nil { + prevThird.NextSibling = third + } else if second.FirstChild == nil { + second.FirstChild = third + } else { + panic("unreachable") + } + prevThird = third + } + } + } + return tree +} + +func TestNewWalk(t *testing.T) { + tree := genFakeTree() + var prefix []string + lastLevel := 0 + tree.Walk(func(t *Thread, lvl int, e error) error { + if e != nil { + fmt.Printf("ERROR: %v\n", e) + } + if lvl > lastLevel && lvl > 1 { + // we actually just descended... so figure out what connector we need + // level 1 is flush to the root, so we avoid the indentation there + if t.Parent.NextSibling != nil { + prefix = append(prefix, "│ ") + } else { + prefix = append(prefix, " ") + } + } else if lvl < lastLevel { + // ascended, need to trim the prefix layers + diff := lastLevel - lvl + prefix = prefix[:len(prefix)-diff] + } + + var arrow string + if t.Parent != nil { + if t.NextSibling != nil { + arrow = "├─>" + } else { + arrow = "└─>" + } + } + + // format + fmt.Printf("%s%s%s\n", strings.Join(prefix, ""), arrow, t) + + lastLevel = lvl + return nil + }) +} + +func uidSeq(tree *Thread) string { + var seq []string + tree.Walk(func(t *Thread, _ int, _ error) error { + seq = append(seq, string(t.Uid)) + return nil + }) + return strings.Join(seq, ".") +} + +func TestThread_AddChild(t *testing.T) { + tests := []struct { + name string + seq []models.UID + want string + }{ + { + name: "ascending", + seq: []models.UID{"1", "2", "3", "4", "5", "6"}, + want: ".1.2.3.4.5.6", + }, + { + name: "descending", + seq: []models.UID{"6", "5", "4", "3", "2", "1"}, + want: ".6.5.4.3.2.1", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + tree := new(Thread) + for _, i := range test.seq { + tree.AddChild(&Thread{Uid: i}) + } + if got := uidSeq(tree); got != test.want { + t.Errorf("got: %s, but wanted: %s", got, + test.want) + } + }) + } +} + +func TestThread_OrderedInsert(t *testing.T) { + tests := []struct { + name string + seq []models.UID + want string + }{ + { + name: "ascending", + seq: []models.UID{"1", "2", "3", "4", "5", "6"}, + want: ".1.2.3.4.5.6", + }, + { + name: "descending", + seq: []models.UID{"6", "5", "4", "3", "2", "1"}, + want: ".1.2.3.4.5.6", + }, + { + name: "mixed", + seq: []models.UID{"2", "1", "6", "3", "4", "5"}, + want: ".1.2.3.4.5.6", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + tree := new(Thread) + for _, i := range test.seq { + tree.OrderedInsert(&Thread{Uid: i}) + } + if got := uidSeq(tree); got != test.want { + t.Errorf("got: %s, but wanted: %s", got, + test.want) + } + }) + } +} + +func TestThread_InsertCmd(t *testing.T) { + tests := []struct { + name string + seq []models.UID + want string + }{ + { + name: "ascending", + seq: []models.UID{"1", "2", "3", "4", "5", "6"}, + want: ".6.4.2.1.3.5", + }, + { + name: "descending", + seq: []models.UID{"6", "5", "4", "3", "2", "1"}, + want: ".6.4.2.1.3.5", + }, + { + name: "mixed", + seq: []models.UID{"2", "1", "6", "3", "4", "5"}, + want: ".6.4.2.1.3.5", + }, + } + sortMap := map[models.UID]int{ + "6": 1, + "4": 2, + "2": 3, + "1": 4, + "3": 5, + "5": 6, + } + + // bigger compares the new child with the next node and returns true if + // the child node is bigger and false otherwise. + bigger := func(newNode, nextChild *Thread) bool { + return sortMap[newNode.Uid] > sortMap[nextChild.Uid] + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + tree := new(Thread) + for _, i := range test.seq { + tree.InsertCmp(&Thread{Uid: i}, bigger) + } + if got := uidSeq(tree); got != test.want { + t.Errorf("got: %s, but wanted: %s", got, + test.want) + } + }) + } +} diff --git a/worker/types/worker.go b/worker/types/worker.go new file mode 100644 index 0000000..92c15bc --- /dev/null +++ b/worker/types/worker.go @@ -0,0 +1,172 @@ +package types + +import ( + "container/list" + "sync" + "sync/atomic" + + "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/models" +) + +type WorkerInteractor interface { + log.Logger + Actions() chan WorkerMessage + ProcessAction(WorkerMessage) WorkerMessage + PostAction(WorkerMessage, func(msg WorkerMessage)) + PostMessage(WorkerMessage, func(msg WorkerMessage)) + Unwrap() WorkerInteractor +} + +var lastId int64 = 1 // access via atomic + +type Backend interface { + Run() + Capabilities() *models.Capabilities + PathSeparator() string +} + +type Worker struct { + Backend Backend + + actions chan WorkerMessage + actionCallbacks map[int64]func(msg WorkerMessage) + messageCallbacks map[int64]func(msg WorkerMessage) + actionQueue *list.List + status int32 + name string + + sync.Mutex + log.Logger +} + +func NewWorker(name string) *Worker { + return &Worker{ + Logger: log.NewLogger(name, 2), + actions: make(chan WorkerMessage), + actionCallbacks: make(map[int64]func(msg WorkerMessage)), + messageCallbacks: make(map[int64]func(msg WorkerMessage)), + actionQueue: list.New(), + name: name, + } +} + +func (worker *Worker) Unwrap() WorkerInteractor { + return nil +} + +func (worker *Worker) Actions() chan WorkerMessage { + return worker.actions +} + +func (worker *Worker) setId(msg WorkerMessage) { + id := atomic.AddInt64(&lastId, 1) + msg.setId(id) +} + +const ( + idle int32 = iota + busy +) + +// Add a new task to the action queue without blocking. Start processing the +// queue in the background if needed. +func (worker *Worker) queue(msg WorkerMessage) { + worker.Lock() + defer worker.Unlock() + worker.actionQueue.PushBack(msg) + if atomic.LoadInt32(&worker.status) == idle { + atomic.StoreInt32(&worker.status, busy) + go worker.processQueue() + } +} + +// Start processing the action queue and write all messages to the actions +// channel, one by one. Stop when the action queue is empty. +func (worker *Worker) processQueue() { + defer log.PanicHandler() + for { + worker.Lock() + e := worker.actionQueue.Front() + if e == nil { + atomic.StoreInt32(&worker.status, idle) + worker.Unlock() + return + } + msg := worker.actionQueue.Remove(e).(WorkerMessage) + worker.Unlock() + worker.actions <- msg + } +} + +// PostAction posts an action to the worker. This method should not be called +// from the same goroutine that the worker runs in or deadlocks may occur +func (worker *Worker) PostAction(msg WorkerMessage, cb func(msg WorkerMessage)) { + worker.setId(msg) + // write to actions channel without blocking + worker.queue(msg) + + if cb != nil { + worker.Lock() + worker.actionCallbacks[msg.getId()] = cb + worker.Unlock() + } +} + +var WorkerMessages = make(chan WorkerMessage, 50) + +// PostMessage posts an message to the UI. This method should not be called +// from the same goroutine that the UI runs in or deadlocks may occur +func (worker *Worker) PostMessage(msg WorkerMessage, + cb func(msg WorkerMessage), +) { + worker.setId(msg) + msg.setAccount(worker.name) + + WorkerMessages <- msg + + if cb != nil { + worker.Lock() + worker.messageCallbacks[msg.getId()] = cb + worker.Unlock() + } +} + +func (worker *Worker) ProcessMessage(msg WorkerMessage) WorkerMessage { + if inResponseTo := msg.InResponseTo(); inResponseTo != nil { + worker.Lock() + f, ok := worker.actionCallbacks[inResponseTo.getId()] + worker.Unlock() + if ok { + f(msg) + switch msg.(type) { + case *Cancelled, *Done: + worker.Lock() + delete(worker.actionCallbacks, inResponseTo.getId()) + worker.Unlock() + } + } + } + return msg +} + +func (worker *Worker) ProcessAction(msg WorkerMessage) WorkerMessage { + if inResponseTo := msg.InResponseTo(); inResponseTo != nil { + worker.Lock() + f, ok := worker.messageCallbacks[inResponseTo.getId()] + worker.Unlock() + if ok { + f(msg) + if _, ok := msg.(*Done); ok { + worker.Lock() + delete(worker.messageCallbacks, inResponseTo.getId()) + worker.Unlock() + } + } + } + return msg +} + +func (worker *Worker) PathSeparator() string { + return worker.Backend.PathSeparator() +} diff --git a/worker/worker.go b/worker/worker.go new file mode 100644 index 0000000..bef5b72 --- /dev/null +++ b/worker/worker.go @@ -0,0 +1,28 @@ +package worker + +import ( + "net/url" + "strings" + + "git.sr.ht/~rjarry/aerc/worker/handlers" + "git.sr.ht/~rjarry/aerc/worker/types" +) + +// Guesses the appropriate worker type based on the given source string +func NewWorker(source string, name string) (*types.Worker, error) { + u, err := url.Parse(source) + if err != nil { + return nil, err + } + worker := types.NewWorker(name) + scheme := u.Scheme + if strings.ContainsRune(scheme, '+') { + scheme = scheme[:strings.IndexRune(scheme, '+')] + } + backend, err := handlers.GetHandlerForScheme(scheme, worker) + if err != nil { + return nil, err + } + worker.Backend = backend + return worker, nil +} diff --git a/worker/worker_enabled.go b/worker/worker_enabled.go new file mode 100644 index 0000000..1eafe40 --- /dev/null +++ b/worker/worker_enabled.go @@ -0,0 +1,9 @@ +package worker + +// the following workers are always enabled +import ( + _ "git.sr.ht/~rjarry/aerc/worker/imap" + _ "git.sr.ht/~rjarry/aerc/worker/jmap" + _ "git.sr.ht/~rjarry/aerc/worker/maildir" + _ "git.sr.ht/~rjarry/aerc/worker/mbox" +)