init: pristine aerc 0.20.0 source

This commit is contained in:
Mortdecai
2026-04-07 19:54:54 -04:00
commit 083402a548
502 changed files with 68722 additions and 0 deletions
+26
View File
@@ -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
+17
View File
@@ -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
+27
View File
@@ -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,
+19
View File
@@ -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
+31
View File
@@ -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" <<EOF
CGO_CFLAGS=-I$(brew --prefix)/include
CGO_LDFLAGS=-L$(brew --prefix)/lib -Wl,-rpath,$(brew --prefix)/lib
EOF
- run: make
- run: make install
- run: make checkinstall
- run: make tests
+14
View File
@@ -0,0 +1,14 @@
/aerc
/aerc.debug
/wrap
/colorize
/linters.so
/*log*
/*.log*
/*.1
/*.5
/*.7
/.env
/.changelog.md
/aerc-release-stats.png
/tags
+17
View File
@@ -0,0 +1,17 @@
[run]
# don't lint tests
tests = false
# enable additional linters
[linters]
enable = [
"nolintlint", # nolint comments require justification
"errorlint", # check to ensure no problems with wrapped errors
"gocritic", # check for bugs, performance, and style issues
"gofmt", # check that gofmt is satisfied
]
[linters-settings.nolintlint]
allow-unused = false # don't allow nolint if not required
require-explanation = true # require an explanation when disabling a linter
requre-specific = true # linter exceptions must specify the linter
+20
View File
@@ -0,0 +1,20 @@
Aditya Srivastava <adityasri163@gmail.com> <adityasri@ocf.berkeley.edu>
Andrew Jeffrey <dev@jeffas.io> <andrewjeffery97@gmail.com>
Andrew Jeffrey <dev@jeffas.io> Andrew Jeffery <dev@jeffas.io>
Andrew Jeffrey <dev@jeffas.io> Jeffas <dev@jeffas.io>
Bor Grošelj Simić <bgs@turminal.net> <bor.groseljsimic@telemach.net>
Christopher Vittal <chris@vittal.dev> <christopher.vittal@gmail.com>
Christopher Vittal <chris@vittal.dev> Chris Vittal <chris@vittal.dev>
Drew DeVault <sir@cmpwn.com> <ddevault@vistarmedia.com>
Inwit <inwit@sindominio.net>
JD <john1doe@ya.ru>
Kalyan Sriram <kalyan@coderkalyan.com> <coder.kalyan@gmail.com>
Kevin Kuehler <kkuehler@brave.com> <keur@ocf.berkeley.edu>
Kevin Kuehler <kkuehler@brave.com> <keur@xcf.berkeley.edu>
Leszek Cimała <ernierasta@zori.cz>
Moritz Poldrack <moritz@poldrack.dev> <git@moritz.sh>
Peter Lamby <dev@peterlamby.de> <Peter.Lamby@direkt-gruppe.de>
Simon Ser <contact@emersion.fr>
Thomas Böhler <witcher@wiredspace.de>
Tim Culverhouse <tim@timculverhouse.com> <tim@tim.culverhouse.com>
Wagner Riffel <wgrriffel@gmail.com> <w@104d.net>
+1
View File
@@ -0,0 +1 @@
debian/patches
+1
View File
@@ -0,0 +1 @@
series
+1
View File
@@ -0,0 +1 @@
2
+2
View File
@@ -0,0 +1,2 @@
fix-blhc.patch
fix-temp-file-creation.patch
+214
View File
@@ -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:
@@ -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
}
+764
View File
@@ -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,~<regexp> =`.
- 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
:<command...>` 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 <cmd>`.
- 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 `<C-z>` 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+<number>` 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
<account>`.
- 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 <account> <folder>`.
- 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 <account> <folder>`.
- Move messages across accounts with `:mv -a <account> <folder>`.
- 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 `<Backspace>` 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 <name>`.
- `:attach -r <name> <cmd>` 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 <folder>`.
- 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 <cmd>` now executes `sh -c "<cmd>"` 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/<scheme>` `[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 `<c-i>` and `<c-m>`.
- 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 `<Enter>` 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 <https://git.sr.ht/~rjarry/aerc>.*
### 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.
+359
View File
@@ -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: <URL>` closes the ticket with the neutral `CLOSED` resolution.
* `Fixes: <URL>` closes the ticket with the `FIXED` resolution.
* `Fixes: <sha> ("<title>")` 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 youve been looking at
> your screen for 20 straight hours, youll 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, its 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 its 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
> shouldnt exceed 5-10, or youre 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 youre brilliant, but maybe youd 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: its much better to write the
> code so that the working is obvious, and its 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
+214
View File
@@ -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:
+22
View File
@@ -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.
+16
View File
@@ -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>
+7
View File
@@ -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
+150
View File
@@ -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).
+886
View File
@@ -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("")
}
}
}
+776
View File
@@ -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())
}
+962
View File
@@ -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)
}
+92
View File
@@ -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)
}
+86
View File
@@ -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()
}
+1995
View File
File diff suppressed because it is too large Load Diff
+70
View File
@@ -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
},
)
}
+559
View File
@@ -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
}
+543
View File
@@ -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 ""
}
+125
View File
@@ -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() {}
+67
View File
@@ -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
}
+44
View File
@@ -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
}
+325
View File
@@ -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)
}
+602
View File
@@ -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)
}
+914
View File
@@ -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
}
+207
View File
@@ -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()
}
}
+98
View File
@@ -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()
}
+30
View File
@@ -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)
}
+243
View File
@@ -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))
}
}
+101
View File
@@ -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()
}
+275
View File
@@ -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)
}
+85
View File
@@ -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()
}
+165
View File
@@ -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
}
+177
View File
@@ -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
}
+64
View File
@@ -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
}
+143
View File
@@ -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()
}
}
+36
View File
@@ -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
}
+48
View File
@@ -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
}
+95
View File
@@ -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
}
+46
View File
@@ -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
}
+52
View File
@@ -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
}
+198
View File
@@ -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
}
+189
View File
@@ -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
}
+64
View File
@@ -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
}
+41
View File
@@ -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
}
+48
View File
@@ -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
}
+108
View File
@@ -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
}
+127
View File
@@ -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)
}
+88
View File
@@ -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
}
+154
View File
@@ -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()
}
})
}
+223
View File
@@ -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)
}
+40
View File
@@ -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
}
+86
View File
@@ -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
}
+82
View File
@@ -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
}
+91
View File
@@ -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
}
+55
View File
@@ -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
}
+53
View File
@@ -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)
}
+28
View File
@@ -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
}
+364
View File
@@ -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
}
+79
View File
@@ -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
}
+44
View File
@@ -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)
}
}
}
+30
View File
@@ -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
}
+29
View File
@@ -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())
}
+217
View File
@@ -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
}
+43
View File
@@ -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
}
+93
View File
@@ -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
}
+46
View File
@@ -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
}
+30
View File
@@ -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
}
+74
View File
@@ -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)
}
+66
View File
@@ -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
}
+40
View File
@@ -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
}
+149
View File
@@ -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
}
+328
View File
@@ -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, &copyBuf)
}
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(),
&copyBuf, 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
}
+47
View File
@@ -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
}
+70
View File
@@ -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
}
+60
View File
@@ -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
}
+30
View File
@@ -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
}
+93
View File
@@ -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
}
+68
View File
@@ -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
}
+81
View File
@@ -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)
}
+140
View File
@@ -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
}
}
+232
View File
@@ -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
}
+23
View File
@@ -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
}
+46
View File
@@ -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
}
+178
View File
@@ -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
}
+214
View File
@@ -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
}
+201
View File
@@ -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())
}
}
+164
View File
@@ -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
}
+134
View File
@@ -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
}

Some files were not shown because too many files have changed in this diff Show More