diff -Nru aerc-0.11.0/aerc.go aerc-0.14.0/aerc.go --- aerc-0.11.0/aerc.go 2022-07-11 19:25:05.000000000 +0000 +++ aerc-0.14.0/aerc.go 2023-01-04 15:38:38.000000000 +0000 @@ -2,15 +2,16 @@ import ( "bytes" + "encoding/base64" + "errors" "fmt" - "io" - "io/ioutil" - "log" "os" + "runtime" "sort" - "time" + "strings" "git.sr.ht/~sircmpwn/getopt" + "github.com/gdamore/tcell/v2" "github.com/mattn/go-isatty" "github.com/xo/terminfo" @@ -25,8 +26,9 @@ "git.sr.ht/~rjarry/aerc/lib/crypto" "git.sr.ht/~rjarry/aerc/lib/templates" libui "git.sr.ht/~rjarry/aerc/lib/ui" - "git.sr.ht/~rjarry/aerc/logging" + "git.sr.ht/~rjarry/aerc/log" "git.sr.ht/~rjarry/aerc/widgets" + "git.sr.ht/~rjarry/aerc/worker/types" ) func getCommands(selected libui.Drawable) []*commands.Commands { @@ -59,29 +61,30 @@ } func execCommand(aerc *widgets.Aerc, ui *libui.UI, cmd []string) error { - cmds := getCommands(aerc.SelectedTab()) + cmds := getCommands(aerc.SelectedTabContent()) for i, set := range cmds { err := set.ExecuteCommand(aerc, cmd) - if _, ok := err.(commands.NoSuchCommand); ok { - if i == len(cmds)-1 { - return err + if err != nil { + if errors.As(err, new(commands.NoSuchCommand)) { + if i == len(cmds)-1 { + return err + } + continue + } + if errors.As(err, new(commands.ErrorExit)) { + ui.Exit() + return nil } - continue - } else if _, ok := err.(commands.ErrorExit); ok { - ui.Exit() - return nil - } else if err != nil { return err - } else { - break } + break } return nil } func getCompletions(aerc *widgets.Aerc, cmd string) []string { var completions []string - for _, set := range getCommands(aerc.SelectedTab()) { + for _, set := range getCommands(aerc.SelectedTabContent()) { completions = append(completions, set.GetCompletions(aerc, cmd)...) } sort.Strings(completions) @@ -90,21 +93,39 @@ // set at build time var Version string +var Flags string + +func buildInfo() string { + info := Version + flags, _ := base64.StdEncoding.DecodeString(Flags) + if strings.Contains(string(flags), "notmuch") { + info += " +notmuch" + } + info += fmt.Sprintf(" (%s %s %s)", + runtime.Version(), runtime.GOARCH, runtime.GOOS) + return info +} -func usage() { - log.Fatal("Usage: aerc [-v] [mailto:...]") +func usage(msg string) { + fmt.Fprintln(os.Stderr, msg) + fmt.Fprintln(os.Stderr, "usage: aerc [-v] [-a ] [mailto:...]") + os.Exit(1) } func setWindowTitle() { + log.Tracef("Parsing terminfo") ti, err := terminfo.LoadFromEnv() if err != nil { + log.Warnf("Cannot get terminfo: %v", err) return } if !ti.Has(terminfo.HasStatusLine) { + log.Infof("Terminal does not have status line support") return } + log.Debugf("Setting terminal title") buf := new(bytes.Buffer) ti.Fprintf(buf, terminfo.ToStatusLine) fmt.Fprint(buf, "aerc") @@ -113,24 +134,27 @@ } func main() { - defer logging.PanicHandler() - opts, optind, err := getopt.Getopts(os.Args, "v") + defer log.PanicHandler() + opts, optind, err := getopt.Getopts(os.Args, "va:") if err != nil { - log.Print(err) - usage() + usage("error: " + err.Error()) return } + log.BuildInfo = buildInfo() + var accts []string for _, opt := range opts { - switch opt.Option { - case 'v': - fmt.Println("aerc " + Version) + if opt.Option == 'v' { + fmt.Println("aerc " + log.BuildInfo) return } + if opt.Option == 'a' { + accts = strings.Split(opt.Value, ",") + } } retryExec := false args := os.Args[optind:] if len(args) > 1 { - usage() + usage("error: invalid arguments") return } else if len(args) == 1 { arg := args[0] @@ -143,25 +167,14 @@ retryExec = true } - var ( - logOut io.Writer - logger *log.Logger - ) - if !isatty.IsTerminal(os.Stdout.Fd()) { - logOut = os.Stdout - } else { - logOut = ioutil.Discard - os.Stdout, _ = os.Open(os.DevNull) - } - logger = log.New(logOut, "", log.LstdFlags) - logger.Println("Starting up aerc") - - conf, err := config.LoadConfigFromFile(nil, logger) + err = config.LoadConfigFromFile(nil, accts) if err != nil { fmt.Fprintf(os.Stderr, "Failed to load config: %v\n", err) - os.Exit(1) + os.Exit(1) //nolint:gocritic // PanicHandler does not need to run as it's not a panic } + log.Infof("Starting up version %s", log.BuildInfo) + var ( aerc *widgets.Aerc ui *libui.UI @@ -169,11 +182,14 @@ deferLoop := make(chan struct{}) - c := crypto.New(conf.General.PgpProvider) - c.Init(logger) + c := crypto.New() + err = c.Init() + if err != nil { + log.Warnf("failed to initialise crypto interface: %v", err) + } defer c.Close() - aerc = widgets.NewAerc(conf, logger, c, func(cmd []string) error { + aerc = widgets.NewAerc(c, func(cmd []string) error { return execCommand(aerc, ui, cmd) }, func(cmd string) []string { return getCompletions(aerc, cmd) @@ -184,22 +200,22 @@ panic(err) } defer ui.Close() - logging.UICleanup = func() { + log.UICleanup = func() { ui.Close() } close(deferLoop) - if conf.Ui.MouseEnabled { + if config.Ui.MouseEnabled { ui.EnableMouse() } - logger.Println("Starting Unix server") - as, err := lib.StartServer(logger) + as, err := lib.StartServer() if err != nil { - logger.Printf("Failed to start Unix server: %v (non-fatal)", err) + log.Warnf("Failed to start Unix server: %v", err) } else { defer as.Close() as.OnMailto = aerc.Mailto + as.OnMbox = aerc.Mbox } // set the aerc version so that we can use it in the template funcs @@ -211,7 +227,10 @@ err := lib.ConnectAndExec(arg) if err != nil { fmt.Fprintf(os.Stderr, "Failed to communicate to aerc: %v\n", err) - aerc.CloseBackends() + err = aerc.CloseBackends() + if err != nil { + log.Warnf("failed to close backends: %v", err) + } return } } @@ -220,14 +239,23 @@ setWindowTitle() } - for !ui.ShouldExit() { - for aerc.Tick() { - // Continue updating our internal state + ui.ChannelEvents() + for event := range libui.MsgChannel { + switch event := event.(type) { + case tcell.Event: + ui.HandleEvent(event) + case *libui.AercFuncMsg: + event.Func() + case types.WorkerMessage: + aerc.HandleMessage(event) } - if !ui.Tick() { - // ~60 FPS - time.Sleep(16 * time.Millisecond) + if ui.ShouldExit() { + break } + ui.Render() + } + err = aerc.CloseBackends() + if err != nil { + log.Warnf("failed to close backends: %v", err) } - aerc.CloseBackends() } diff -Nru aerc-0.11.0/.builds/alpine-edge.yml aerc-0.14.0/.builds/alpine-edge.yml --- aerc-0.11.0/.builds/alpine-edge.yml 2022-07-11 19:25:05.000000000 +0000 +++ aerc-0.14.0/.builds/alpine-edge.yml 2023-01-04 15:38:38.000000000 +0000 @@ -25,8 +25,11 @@ cd aerc go test ./... - ancient-go-version: | - curl -O https://dl-cdn.alpinelinux.org/alpine/v3.11/community/x86_64/go-1.13.13-r0.apk - sudo apk add ./go-1.13.13-r0.apk + curl -O https://dl-cdn.alpinelinux.org/alpine/v3.14/community/x86_64/go-1.16.15-r0.apk + sudo apk add ./go-1.16.15-r0.apk cd aerc make clean make + - check-patches: | + cd aerc + make check-patches diff -Nru aerc-0.11.0/CHANGELOG.md aerc-0.14.0/CHANGELOG.md --- aerc-0.11.0/CHANGELOG.md 1970-01-01 00:00:00.000000000 +0000 +++ aerc-0.14.0/CHANGELOG.md 2023-01-04 15:38:38.000000000 +0000 @@ -0,0 +1,340 @@ +# 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/). + +## [Unreleased](https://git.sr.ht/~rjarry/aerc/log/master) + +## [0.14.0](https://git.sr.ht/~rjarry/aerc/refs/0.14.0) - 2023-01-04 + +### Added + +- View common email envelope headers with `:envelope`. +- Notmuch accounts now support maildir operations: `:copy`, `:move`, `:mkdir`, + `:rmdir`, `:archive` and the `copy-to` option. +- Display messages from bottom to top with `[ui].reverse-msglist-order=true` in + `aerc.conf`. +- Display threads from bottom to top with `[ui].reverse-thread-order=true` in + `aerc.conf`. +- Style search results in the message list with `msglist_result.*`. +- Preview messages with their attachments before sending with `:preview`. +- Filter commands now have `AERC_FORMAT`, `AERC_SUBJECT` and `AERC_FROM` + defined in their environment. +- Override the subject prefix for replies pattern with `subject-re-pattern` in + `accounts.conf`. +- Search/filter by absolute and relative date ranges with the `-d` flag. +- LIST-STATUS and ORDEREDSUBJECT threading extensions support for imap. +- Built-in `wrap` filter that does not mess up nested quotes and lists. +- Write `multipart/alternative` messages with `:multipart` and commands defined + in the new `[multipart-converters]` section of `aerc.conf`. +- Close the message viewer before opening the composer with `:reply -c`. +- Attachment indicator in message list flags (by default `a`, but can be + changed via `[ui].icon-attachment` in `aerc.conf`). +- Open file picker menu with `:attach -m`. The menu must be generated by an + external command configured via `[compose].file-picker-cmd` in `aerc.conf`. +- Sample stylesets are now installed in `$PREFIX/share/aerc/stylesets`. +- The built-in `colorize` filter now has different themes. + +### Changed + +- `pgp-provider` now defaults to `auto`. It will use the system `gpg` unless + the internal keyring exists and contains at least one key. +- Calling `:split` or `:vsplit` without specifying a size, now attempts to use + the terminal size to determine a useful split-size. + +### Fixed + +- `:pipe -m git am -3` on patch series when `Message-Id` headers have not been + generated by `git send-email`. +- Overflowing text in header editors while composing can now be scrolled + horizontally. + +### Deprecated + +- Removed broken `:set` command. + +## [0.13.0](https://git.sr.ht/~rjarry/aerc/refs/0.13.0) - 2022-10-20 + +### Added + +- Support for bindings with the Alt modifier. +- Zoxide support with `:z`. +- Hide local timezone with `send-as-utc = true` in `accounts.conf`. +- Persistent command history in `~/.cache/aerc/history`. +- Cursor shape support in embedded terminals. +- Bracketed paste support. +- Display current directory in `status-line.render-format` with `%p`. +- Change accounts while composing a message with `:switch-account`. +- Override `:open` handler on a per-MIME-type basis in `aerc.conf`. +- Specify opener as the first `:open` param instead of always using default + handler (i.e. `:open gimp` to open attachment in GIMP). +- Restored XOAUTH2 support for IMAP and SMTP. +- Support for attaching files with `mailto:`-links +- Filter commands now have the `AERC_MIME_TYPE` and `AERC_FILENAME` variables + defined in their environment. +- Warn before sending emails that may need an attachment with + `no-attachment-warning` in `aerc.conf`. +- 3 panel view via `:split` and `:vsplit` +- Configure dynamic date format for the message viewer with + `message-view-this-*-time-format`. +- View message without marking it as seen with `:view -p`. + +### Changed + +- `:open-link` now supports link types other than HTTP(S) +- Running the same command multiple times only adds one entry to the command + history. +- Embedded terminal backend (libvterm was replaced by a pure go implementation). +- Filter commands are now executed with + `:~/.config/aerc/filters:~/.local/share/aerc/filters:$PREFIX/share/aerc/filters:/usr/share/aerc/filters` + appended to the exec `PATH`. This allows referencing aerc's built-in filter + scripts from their name only. + +### Fixed + +- `:open-link` will now detect links containing an exclamation mark +- `outgoing-cred-cmd` will no longer be executed every time an email needs to + be sent. The output will be stored until aerc is shut down. This behaviour + can be disabled by setting `outgoing-cred-cmd-cache=false` in + `accounts.conf`. +- Mouse support for embedded editors when `mouse-enabled=true`. +- Numerous race conditions. + +## [0.12.0](https://git.sr.ht/~rjarry/aerc/refs/0.12.0) - 2022-09-01 + +### Added + +- Read-only mbox backend support. +- Import/Export mbox files with `:import-mbox` and `:export-mbox`. +- `address-book-cmd` can now also be specified in `accounts.conf`. +- Run `check-mail-cmd` with `:check-mail`. +- Display active key binds with `:help keys` (bound to `?` by default). +- Multiple visual selections with `:mark -V`. +- Mark all messages of the same thread with `:mark -T`. +- Set default collapse depth of directory tree with `dirlist-collapse`. + +### Changed + +- Aerc will no longer exit while a send is in progress. +- When scrolling through large folders, client side threading is now debounced + to avoid lagging. This can be configured with `client-threads-delay`. +- The provided awk filters are now POSIX compliant and should work on MacOS and + BSD. +- `outgoing-cred-cmd` execution is now deferred until a message needs to be sent. +- `next-message-on-delete` now also applies to `:archive`. +- `:attach` now supports path globbing (`:attach *.log`) + +### Fixed + +- Transient crashes when closing tabs. +- Binding a command to `` and ``. +- Reselection after delete and scroll when client side threading is enabled. +- Background mail count polling when the default folder is empty on startup. +- Wide character handling in the message list. +- Issues with message reselection during scrolling and after `:delete` with + threading enabled. + +### Deprecated + +- Removed support for go < 1.16. + +## [0.11.0](https://git.sr.ht/~rjarry/aerc/refs/0.11.0) - 2022-07-11 + +### Added + +- Deal with calendar invites with `:accept`, `:accept-tentative` and `:decline`. +- IMAP cache support. +- Maildir++ support. +- Background mail count polling for all folders. +- Authentication-Results display (DKIM, SPF & DMARC). +- Folder-specific key bindings. +- Customizable PGP icons. +- Open URLs from messages with `:open-link`. +- Forward all individual attachments with `:forward -A`. + +### Changed + +- Messages are now deselected after performing a command. Use `:remark` to + reselect the previously selected messages and chain other commands. +- Pressing `` in the default postpone folder now runs `:recall` instead + of `:view`. +- PGP signed/encrypted indicators have been reworked. +- The `threading-enabled` option now affects if message threading should be + enabled at startup. This option no longer conflicts with `:toggle-threads`. + +### Fixed + +- `:pipe`, `:save` and `:open` for signed and/or encrypted PGP messages. +- Messages that have failed `gpg` encryption/signing are no longer sent. +- Recalling attachments from drafts. + +## [0.10.0](https://git.sr.ht/~rjarry/aerc/refs/0.10.0) - 2022-05-07 + +### Added + +- Format specifier for compact folder names in dirlist. +- Customizable, per-folder status line. +- Allow binding commands to `<` and `>` keys. +- Optional filter to parse ICS files (uses `python3` vobject library). +- Save all attachments with `:save -a`. +- Native `gpg` support. +- PGP `auto-sign` and `opportunistic-encrypt` options. +- Attach your PGP public key to a message with `:attach-key`. + +### Fixed + +- Stack overflow with faulty `References` headers when `:toggle-threads` is + enabled. + +## [0.9.0](https://git.sr.ht/~rjarry/aerc/refs/0.9.0) - 2022-03-21 + +### Added + +- Allow `:pipe` on multiple selected messages. +- Client side on-the-fly message threading with `:toggle-threads` (conflicts + with existing `threading-enabled` option). +- Per-account, better status line. +- Consecutive, incremental `:search` and `:filter` support. +- Foldable tree for directory list. +- `Bcc` and `Body` in `mailto:` handler. +- Fuzzy tab completion for commands and folders. +- Key pass though mode for the message viewer to allow searching with `less`. + +### Changed + +- Use terminfo for setting terminal title. + +## [0.8.2](https://git.sr.ht/~rjarry/aerc/refs/0.8.2) - 2022-02-19 + +### Added + +- New `colorize` filter with diff, multi-level quotes and URL coloring. +- XDG desktop entry to use as default `mailto:` handler. +- IMAP automatic reconnect. +- Recover drafts after crash with `:recover`. +- Show possible actions with user configured bindings when reviewing a message. +- Allow setting any header in email templates. +- Improved `:change-folder` responsiveness. +- New `:compose` option to never include your own address when replying. + +### Changed + +- Templates and style sets are now searched from multiple directories. Not from + a single hard-coded folder set at build time. In addition of the configured + `PREFIX/share/aerc` folders at build time, aerc now also looks into + `~/.config/aerc`, `~/.local/share/aerc`, `/usr/local/share/aerc` and + `/usr/share/aerc` +- A warning is displayed when trying to configure account specific bindings + for a non-existent account. + +### Fixed + +- `Ctrl-h` binding not working. +- Open files leaks for maildir and notmuch. + +## 0.8.1 - 2022-02-20 [YANKED] + +## 0.8.0 - 2022-02-19 [YANKED] + +## [0.7.1](https://git.sr.ht/~rjarry/aerc/refs/0.7.1) - 2022-01-15 + +### Added + +- IMAP low level TCP settings. +- Experimental IMAP server-side and notmuch threading. +- `:recall` now works from any folder. +- PGP/MIME signing and encryption. +- Account specific bindings. + +### Fixed + +- Address book completion for multiple addresses. +- Maildir external mailbox changes monitoring. + +## 0.7.0 - 2022-01-14 [YANKED] + +## [0.6.0](https://git.sr.ht/~rjarry/aerc/refs/0.6.0) - 2021-11-09 + +*The project was forked to .* + +### Added + +- Allow more modifiers for key bindings. +- Dynamic dates in message list. +- Match any header in filters specifiers. + +### Fixed + +- Don't read entire messages into memory. + +## [0.5.0](https://git.sr.ht/~sircmpwn/aerc/refs/0.5.0) - 2020-11-10 + +### Added + +- Remove folder with `:rmdir`. +- Configurable style sets. +- UI context aware options and styling. +- oauthbearer support for SMTP. +- IMAP sort support. + +## [0.4.0](https://git.sr.ht/~sircmpwn/aerc/refs/0.4.0) - 2020-05-20 + +### Added + +- Address book completion. +- Initial PGP support using an internal key store. +- Messages can now be selected with `:mark`. +- Drafts handing with `:postpone` and `:recall`. +- Tab management with `:move-tab` and `:pin-tab`. +- Add arbitrary headers in the compose window with `:header`. +- Interactive prompt with `:choose`. +- Notmuch labels improvements. +- Support setting some headers in message templates. + +### Changed + +- `aerc.conf` ini parser only uses `=` as delimiter. `:` is now ignored. + +## [0.3.0](https://git.sr.ht/~sircmpwn/aerc/refs/0.3.0) - 2019-11-21 + +### Added + +- A new notmuch backend is available. See `aerc-notmuch(5)` for details. +- Message templates now let you change the default reply and forwarded message + templates, as well as add new templates of your own. See `aerc-templates(7)` + for details. +- Mouse input is now optionally available and has been rigged up throughout the + UI, set `[ui]mouse-enabled=true` in `aerc.conf` to enable. +- `:cc` and `:bcc` commands are available in the message composer. +- Users may now configure arbitrary message headers for editing in the message + composer. + +## [0.2.0](https://git.sr.ht/~sircmpwn/aerc/refs/0.2.0) - 2019-07-29 + +### Added + +- Maildir & sendmail transport support +- Search and filtering are supported (via `/` and `\` by default) +- `aerc mailto:...` now opens the composer in running aerc instance +- Initial tab completion support has been added +- Improved headers and addressing in the composer and message view +- Message attachments may now be added in the composer +- Commands can now be run in the background with `:exec` or `:pipe -b` +- A new triggers system allows running aerc commands when new emails arrive, + which may (for example) be used to send desktop notifications or move new + emails to a folder + +### Changed + +- The filters have been rewritten in awk, dropping the Python dependencies. + `w3m` and `dante` are both still required for HTML email, but the HTML filter + has been commented out in the default config file. +- The default keybindings and configuration options have changed considerably, + and users are encouraged to pull the latest versions out of `/usr/share` and + re-apply their modifications to them, or to at least review the diff with + their current configurations. aerc may not behave properly without taking + this into account. + +## [0.1.0](https://git.sr.ht/~sircmpwn/aerc/refs/0.1.0) - 2019-06-03 + +Initial release. diff -Nru aerc-0.11.0/commands/account/account.go aerc-0.14.0/commands/account/account.go --- aerc-0.11.0/commands/account/account.go 2022-07-11 19:25:05.000000000 +0000 +++ aerc-0.14.0/commands/account/account.go 2023-01-04 15:38:38.000000000 +0000 @@ -4,9 +4,7 @@ "git.sr.ht/~rjarry/aerc/commands" ) -var ( - AccountCommands *commands.Commands -) +var AccountCommands *commands.Commands func register(cmd commands.Command) { if AccountCommands == nil { diff -Nru aerc-0.11.0/commands/account/cf.go aerc-0.14.0/commands/account/cf.go --- aerc-0.11.0/commands/account/cf.go 2022-07-11 19:25:05.000000000 +0000 +++ aerc-0.14.0/commands/account/cf.go 2023-01-04 15:38:38.000000000 +0000 @@ -9,9 +9,7 @@ "git.sr.ht/~rjarry/aerc/widgets" ) -var ( - history map[string]string -) +var history map[string]string type ChangeFolder struct{} diff -Nru aerc-0.11.0/commands/account/check-mail.go aerc-0.14.0/commands/account/check-mail.go --- aerc-0.11.0/commands/account/check-mail.go 1970-01-01 00:00:00.000000000 +0000 +++ aerc-0.14.0/commands/account/check-mail.go 2023-01-04 15:38:38.000000000 +0000 @@ -0,0 +1,31 @@ +package account + +import ( + "errors" + + "git.sr.ht/~rjarry/aerc/widgets" +) + +type CheckMail struct{} + +func init() { + register(CheckMail{}) +} + +func (CheckMail) Aliases() []string { + return []string{"check-mail"} +} + +func (CheckMail) Complete(aerc *widgets.Aerc, args []string) []string { + return nil +} + +func (CheckMail) Execute(aerc *widgets.Aerc, args []string) error { + acct := aerc.SelectedAccount() + if acct == nil { + return errors.New("No account selected") + } + acct.CheckMailReset() + acct.CheckMail() + return nil +} diff -Nru aerc-0.11.0/commands/account/clear.go aerc-0.14.0/commands/account/clear.go --- aerc-0.11.0/commands/account/clear.go 2022-07-11 19:25:05.000000000 +0000 +++ aerc-0.14.0/commands/account/clear.go 2023-01-04 15:38:38.000000000 +0000 @@ -39,8 +39,7 @@ } for _, opt := range opts { - switch opt.Option { - case 's': + if opt.Option == 's' { clearSelected = true } } @@ -51,8 +50,6 @@ if clearSelected { defer store.Select(0) - } else { - store.SetReselect(store.Selected()) } store.ApplyClear() acct.SetStatus(statusline.SearchFilterClear()) diff -Nru aerc-0.11.0/commands/account/compose.go aerc-0.14.0/commands/account/compose.go --- aerc-0.11.0/commands/account/compose.go 2022-07-11 19:25:05.000000000 +0000 +++ aerc-0.14.0/commands/account/compose.go 2023-01-04 15:38:38.000000000 +0000 @@ -10,7 +10,9 @@ "github.com/emersion/go-message/mail" - "git.sr.ht/~rjarry/aerc/logging" + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/lib/ui" + "git.sr.ht/~rjarry/aerc/log" "git.sr.ht/~rjarry/aerc/models" "git.sr.ht/~rjarry/aerc/widgets" "git.sr.ht/~sircmpwn/getopt" @@ -40,7 +42,7 @@ return errors.New("No account selected") } if template == "" { - template = aerc.Config().Templates.NewMessage + template = config.Templates.NewMessage } msg, err := gomail.ReadMessage(strings.NewReader(body)) @@ -52,7 +54,7 @@ headers := mail.HeaderFromMap(msg.Header) composer, err := widgets.NewComposer(aerc, acct, - aerc.Config(), acct.AccountConfig(), acct.Worker(), + acct.AccountConfig(), acct.Worker(), template, &headers, models.OriginalMail{}) if err != nil { return err @@ -64,10 +66,10 @@ } else { tab.Name = subject } - tab.Content.Invalidate() + ui.Invalidate() }) go func() { - defer logging.PanicHandler() + defer log.PanicHandler() composer.AppendContents(msg.Body) }() diff -Nru aerc-0.11.0/commands/account/export-mbox.go aerc-0.14.0/commands/account/export-mbox.go --- aerc-0.11.0/commands/account/export-mbox.go 1970-01-01 00:00:00.000000000 +0000 +++ aerc-0.14.0/commands/account/export-mbox.go 2023-01-04 15:38:38.000000000 +0000 @@ -0,0 +1,128 @@ +package account + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "git.sr.ht/~rjarry/aerc/log" + "git.sr.ht/~rjarry/aerc/widgets" + mboxer "git.sr.ht/~rjarry/aerc/worker/mbox" + "git.sr.ht/~rjarry/aerc/worker/types" +) + +type ExportMbox struct{} + +func init() { + register(ExportMbox{}) +} + +func (ExportMbox) Aliases() []string { + return []string{"export-mbox"} +} + +func (ExportMbox) Complete(aerc *widgets.Aerc, args []string) []string { + if acct := aerc.SelectedAccount(); acct != nil { + if path := acct.SelectedDirectory(); path != "" { + if f := filepath.Base(path); f != "" { + return []string{f + ".mbox"} + } + } + } + return nil +} + +func (ExportMbox) Execute(aerc *widgets.Aerc, args []string) error { + if len(args) != 2 { + return exportFolderUsage(args[0]) + } + filename := args[1] + + acct := aerc.SelectedAccount() + if acct == nil { + return errors.New("No account selected") + } + store := acct.Store() + if store == nil { + return errors.New("No message store selected") + } + + aerc.PushStatus("Exporting to "+filename, 10*time.Second) + + go func() { + file, err := os.Create(filename) + if err != nil { + log.Errorf("failed to create file: %v", err) + aerc.PushError(err.Error()) + return + } + defer file.Close() + + var mu sync.Mutex + var ctr uint32 + var retries int + + done := make(chan bool) + uids := make([]uint32, len(store.Uids())) + copy(uids, store.Uids()) + t := time.Now() + + for len(uids) > 0 { + if retries > 0 { + if retries > 10 { + errorMsg := fmt.Sprintf("too many retries: %d; stopping export", retries) + log.Errorf(errorMsg) + aerc.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) + aerc.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, len(store.Uids()), filename) + aerc.PushStatus(statusInfo, 10*time.Second) + log.Debugf(statusInfo) + }() + + return nil +} + +func exportFolderUsage(cmd string) error { + return fmt.Errorf("Usage: %s ", cmd) +} diff -Nru aerc-0.11.0/commands/account/import-mbox.go aerc-0.14.0/commands/account/import-mbox.go --- aerc-0.11.0/commands/account/import-mbox.go 1970-01-01 00:00:00.000000000 +0000 +++ aerc-0.14.0/commands/account/import-mbox.go 2023-01-04 15:38:38.000000000 +0000 @@ -0,0 +1,153 @@ +package account + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sync/atomic" + "time" + + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/log" + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/widgets" + mboxer "git.sr.ht/~rjarry/aerc/worker/mbox" + "git.sr.ht/~rjarry/aerc/worker/types" +) + +type ImportMbox struct{} + +func init() { + register(ImportMbox{}) +} + +func (ImportMbox) Aliases() []string { + return []string{"import-mbox"} +} + +func (ImportMbox) Complete(aerc *widgets.Aerc, args []string) []string { + return commands.CompletePath(filepath.Join(args...)) +} + +func (ImportMbox) Execute(aerc *widgets.Aerc, args []string) error { + if len(args) != 2 { + return importFolderUsage(args[0]) + } + filename := args[1] + + acct := aerc.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() { + statusInfo := fmt.Sprintln("Importing", filename, "to folder", folder) + aerc.PushStatus(statusInfo, 10*time.Second) + log.Debugf(statusInfo) + f, err := os.Open(filename) + if err != nil { + aerc.PushError(err.Error()) + return + } + defer f.Close() + + messages, err := mboxer.Read(f) + if err != nil { + aerc.PushError(err.Error()) + return + } + worker := acct.Worker() + + 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) + worker.PostAction(&types.AppendMessage{ + Destination: folder, + Flags: []models.Flag{models.SeenFlag}, + Date: time.Now(), + Reader: &buf, + Length: 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) + aerc.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 sucessfully.", args[0], appended, len(messages)) + log.Debugf(infoStr) + aerc.SetStatus(infoStr) + } + + if len(store.Uids()) > 0 { + confirm := widgets.NewSelectorDialog( + "Selected directory is not empty", + fmt.Sprintf("Import mbox file to %s anyways?", folder), + []string{"No", "Yes"}, 0, aerc.SelectedAccountUiConfig(), + func(option string, err error) { + aerc.CloseDialog() + aerc.Invalidate() + if option == "Yes" { + go importFolder() + } + }, + ) + aerc.AddDialog(confirm) + } else { + go importFolder() + } + + return nil +} + +func importFolderUsage(cmd string) error { + return fmt.Errorf("Usage: %s ", cmd) +} diff -Nru aerc-0.11.0/commands/account/next.go aerc-0.14.0/commands/account/next.go --- aerc-0.11.0/commands/account/next.go 2022-07-11 19:25:05.000000000 +0000 +++ aerc-0.14.0/commands/account/next.go 2023-01-04 15:38:38.000000000 +0000 @@ -6,6 +6,7 @@ "strconv" "strings" + "git.sr.ht/~rjarry/aerc/lib/ui" "git.sr.ht/~rjarry/aerc/widgets" ) @@ -65,13 +66,13 @@ store := acct.Store() if store != nil { store.NextPrev(-n) - acct.Messages().Invalidate() + ui.Invalidate() } } else { store := acct.Store() if store != nil { store.NextPrev(n) - acct.Messages().Invalidate() + ui.Invalidate() } } return nil diff -Nru aerc-0.11.0/commands/account/next-result.go aerc-0.14.0/commands/account/next-result.go --- aerc-0.11.0/commands/account/next-result.go 2022-07-11 19:25:05.000000000 +0000 +++ aerc-0.14.0/commands/account/next-result.go 2023-01-04 15:38:38.000000000 +0000 @@ -4,6 +4,7 @@ "errors" "fmt" + "git.sr.ht/~rjarry/aerc/lib/ui" "git.sr.ht/~rjarry/aerc/widgets" ) @@ -34,13 +35,13 @@ if store != nil { store.PrevResult() } - acct.Messages().Invalidate() + ui.Invalidate() } else { store := acct.Store() if store != nil { store.NextResult() } - acct.Messages().Invalidate() + ui.Invalidate() } return nil } diff -Nru aerc-0.11.0/commands/account/recover.go aerc-0.14.0/commands/account/recover.go --- aerc-0.11.0/commands/account/recover.go 2022-07-11 19:25:05.000000000 +0000 +++ aerc-0.14.0/commands/account/recover.go 2023-01-04 15:38:38.000000000 +0000 @@ -3,12 +3,13 @@ import ( "bytes" "errors" - "io/ioutil" + "io" "os" "path/filepath" "git.sr.ht/~rjarry/aerc/commands" - "git.sr.ht/~rjarry/aerc/logging" + "git.sr.ht/~rjarry/aerc/lib/ui" + "git.sr.ht/~rjarry/aerc/log" "git.sr.ht/~rjarry/aerc/models" "git.sr.ht/~rjarry/aerc/widgets" "git.sr.ht/~sircmpwn/getopt" @@ -36,9 +37,10 @@ if len(args) == 0 { return files } - if args[0] == "-" { + switch args[0] { + case "-": return []string{"-f"} - } else if args[0] == "-f" { + case "-f": if len(args) == 1 { for i, file := range files { files[i] = args[0] + " " + file @@ -49,7 +51,7 @@ return commands.FilterList(files, args[1], args[0]+" ", aerc.SelectedAccountUiConfig().FuzzyComplete) } - } else { + default: // only accepts one file to recover return commands.FilterList(files, args[0], "", aerc.SelectedAccountUiConfig().FuzzyComplete) } @@ -68,8 +70,7 @@ return err } for _, opt := range opts { - switch opt.Option { - case 'f': + if opt.Option == 'f' { force = true } } @@ -89,7 +90,7 @@ return nil, err } defer recoverFile.Close() - data, err := ioutil.ReadAll(recoverFile) + data, err := io.ReadAll(recoverFile) if err != nil { return nil, err } @@ -101,7 +102,7 @@ } composer, err := widgets.NewComposer(aerc, acct, - aerc.Config(), acct.AccountConfig(), acct.Worker(), + acct.AccountConfig(), acct.Worker(), "", nil, models.OriginalMail{}) if err != nil { return err @@ -110,10 +111,10 @@ tab := aerc.NewTab(composer, "Recovered") composer.OnHeaderChange("Subject", func(subject string) { tab.Name = subject - tab.Content.Invalidate() + ui.Invalidate() }) go func() { - defer logging.PanicHandler() + defer log.PanicHandler() composer.AppendContents(bytes.NewReader(data)) }() diff -Nru aerc-0.11.0/commands/account/rmdir.go aerc-0.14.0/commands/account/rmdir.go --- aerc-0.11.0/commands/account/rmdir.go 2022-07-11 19:25:05.000000000 +0000 +++ aerc-0.14.0/commands/account/rmdir.go 2023-01-04 15:38:38.000000000 +0000 @@ -37,8 +37,7 @@ return err } for _, opt := range opts { - switch opt.Option { - case 'f': + if opt.Option == 'f' { force = true } } diff -Nru aerc-0.11.0/commands/account/search.go aerc-0.14.0/commands/account/search.go --- aerc-0.11.0/commands/account/search.go 2022-07-11 19:25:05.000000000 +0000 +++ aerc-0.14.0/commands/account/search.go 2023-01-04 15:38:38.000000000 +0000 @@ -5,6 +5,8 @@ "strings" "git.sr.ht/~rjarry/aerc/lib/statusline" + "git.sr.ht/~rjarry/aerc/lib/ui" + "git.sr.ht/~rjarry/aerc/log" "git.sr.ht/~rjarry/aerc/widgets" "git.sr.ht/~rjarry/aerc/worker/types" ) @@ -42,18 +44,18 @@ cb := func(msg types.WorkerMessage) { if _, ok := msg.(*types.Done); ok { acct.SetStatus(statusline.FilterResult(strings.Join(args, " "))) - acct.Logger().Printf("Filter results: %v", store.Uids()) + log.Tracef("Filter results: %v", store.Uids()) } } - store.Sort(nil, cb) + store.Sort(store.GetCurrentSortCriteria(), cb) } else { acct.SetStatus(statusline.Search("Searching...")) cb := func(uids []uint32) { acct.SetStatus(statusline.Search(strings.Join(args, " "))) - acct.Logger().Printf("Search results: %v", uids) + log.Tracef("Search results: %v", uids) store.ApplySearch(uids) // TODO: Remove when stores have multiple OnUpdate handlers - acct.Messages().Invalidate() + ui.Invalidate() } store.Search(args, cb) } diff -Nru aerc-0.11.0/commands/account/sort.go aerc-0.14.0/commands/account/sort.go --- aerc-0.11.0/commands/account/sort.go 2022-07-11 19:25:05.000000000 +0000 +++ aerc-0.14.0/commands/account/sort.go 2023-01-04 15:38:38.000000000 +0000 @@ -72,6 +72,12 @@ 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 { diff -Nru aerc-0.11.0/commands/account/split.go aerc-0.14.0/commands/account/split.go --- aerc-0.11.0/commands/account/split.go 1970-01-01 00:00:00.000000000 +0000 +++ aerc-0.14.0/commands/account/split.go 2023-01-04 15:38:38.000000000 +0000 @@ -0,0 +1,74 @@ +package account + +import ( + "errors" + "strconv" + "strings" + + "git.sr.ht/~rjarry/aerc/widgets" +) + +type Split struct{} + +func init() { + register(Split{}) +} + +func (Split) Aliases() []string { + return []string{"split", "vsplit"} +} + +func (Split) Complete(aerc *widgets.Aerc, args []string) []string { + return nil +} + +func (Split) Execute(aerc *widgets.Aerc, args []string) error { + if len(args) > 2 { + return errors.New("Usage: [v]split n") + } + acct := aerc.SelectedAccount() + if acct == nil { + return errors.New("No account selected") + } + n := 0 + if acct.SplitSize() == 0 { + if args[0] == "split" { + n = aerc.SelectedAccount().Messages().Height() / 4 + } else { + n = aerc.SelectedAccount().Messages().Width() / 2 + } + } + + var err error + if len(args) > 1 { + delta := false + if strings.HasPrefix(args[1], "+") || strings.HasPrefix(args[1], "-") { + delta = true + } + n, err = strconv.Atoi(args[1]) + if err != nil { + return errors.New("Usage: [v]split n") + } + if delta { + n = acct.SplitSize() + n + acct.SetSplitSize(n) + return nil + } + } + if n == acct.SplitSize() { + // Repeated commands of the same size have the effect of + // toggling the split + n = 0 + } + if n < 0 { + // Don't allow split to go negative + n = 1 + } + switch args[0] { + case "split": + return acct.Split(n) + case "vsplit": + return acct.Vsplit(n) + } + return nil +} diff -Nru aerc-0.11.0/commands/account/view.go aerc-0.14.0/commands/account/view.go --- aerc-0.11.0/commands/account/view.go 2022-07-11 19:25:05.000000000 +0000 +++ aerc-0.14.0/commands/account/view.go 2023-01-04 15:38:38.000000000 +0000 @@ -5,6 +5,7 @@ "git.sr.ht/~rjarry/aerc/lib" "git.sr.ht/~rjarry/aerc/widgets" + "git.sr.ht/~sircmpwn/getopt" ) type ViewMessage struct{} @@ -22,8 +23,20 @@ } func (ViewMessage) Execute(aerc *widgets.Aerc, args []string) error { - if len(args) != 1 { - return errors.New("Usage: view-message") + peek := false + opts, optind, err := getopt.Getopts(args, "p") + if err != nil { + return err + } + + for _, opt := range opts { + if opt.Option == 'p' { + peek = true + } + } + + if len(args) != optind { + return errors.New("Usage: view-message [-p]") } acct := aerc.SelectedAccount() if acct == nil { @@ -45,13 +58,14 @@ aerc.PushError(msg.Error.Error()) return nil } - lib.NewMessageStoreView(msg, store, aerc.Crypto, aerc.DecryptKeys, + lib.NewMessageStoreView(msg, !peek && acct.UiConfig().AutoMarkRead, + store, aerc.Crypto, aerc.DecryptKeys, func(view lib.MessageView, err error) { if err != nil { aerc.PushError(err.Error()) return } - viewer := widgets.NewMessageViewer(acct, aerc.Config(), view) + viewer := widgets.NewMessageViewer(acct, view) aerc.NewTab(viewer, msg.Envelope.Subject) }) return nil diff -Nru aerc-0.11.0/commands/cd.go aerc-0.14.0/commands/cd.go --- aerc-0.11.0/commands/cd.go 2022-07-11 19:25:05.000000000 +0000 +++ aerc-0.14.0/commands/cd.go 2023-01-04 15:38:38.000000000 +0000 @@ -9,9 +9,7 @@ "github.com/mitchellh/go-homedir" ) -var ( - previousDir string -) +var previousDir string type ChangeDirectory struct{} @@ -62,6 +60,7 @@ } if err := os.Chdir(target); err == nil { previousDir = cwd + aerc.UpdateStatus() } return err } diff -Nru aerc-0.11.0/commands/compose/abort.go aerc-0.14.0/commands/compose/abort.go --- aerc-0.11.0/commands/compose/abort.go 2022-07-11 19:25:05.000000000 +0000 +++ aerc-0.14.0/commands/compose/abort.go 2023-01-04 15:38:38.000000000 +0000 @@ -24,7 +24,7 @@ if len(args) != 1 { return errors.New("Usage: abort") } - composer, _ := aerc.SelectedTab().(*widgets.Composer) + composer, _ := aerc.SelectedTabContent().(*widgets.Composer) aerc.RemoveTab(composer) composer.Close() diff -Nru aerc-0.11.0/commands/compose/attach.go aerc-0.14.0/commands/compose/attach.go --- aerc-0.11.0/commands/compose/attach.go 2022-07-11 19:25:05.000000000 +0000 +++ aerc-0.14.0/commands/compose/attach.go 2023-01-04 15:38:38.000000000 +0000 @@ -1,11 +1,19 @@ package compose import ( + "bufio" + "errors" "fmt" + "io" "os" + "os/exec" + "path/filepath" "strings" "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/lib/ui" + "git.sr.ht/~rjarry/aerc/log" "git.sr.ht/~rjarry/aerc/widgets" "github.com/mitchellh/go-homedir" ) @@ -25,32 +33,146 @@ return commands.CompletePath(path) } -func (Attach) Execute(aerc *widgets.Aerc, args []string) error { +func (a Attach) Execute(aerc *widgets.Aerc, args []string) error { if len(args) == 1 { return fmt.Errorf("Usage: :attach ") } - path := strings.Join(args[1:], " ") + if args[1] == "-m" { + return a.openMenu(aerc, args[2:]) + } + + return a.addPath(aerc, strings.Join(args[1:], " ")) +} +func (a Attach) addPath(aerc *widgets.Aerc, path string) error { path, err := homedir.Expand(path) if err != nil { + log.Errorf("failed to expand path '%s': %v", path, err) aerc.PushError(err.Error()) return err } - pathinfo, err := os.Stat(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, _ := aerc.SelectedTabContent().(*widgets.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) + aerc.PushError(err.Error()) + return err + } else if pathinfo.IsDir() && len(attachments) == 1 { + aerc.PushError("Attachment must be a file, not a directory") + return nil + } + + composer.AddAttachment(attach) + } + + if len(attachments) == 1 { + aerc.PushSuccess(fmt.Sprintf("Attached %s", path)) + } else { + aerc.PushSuccess(fmt.Sprintf("Attached %d files", len(attachments))) + } + + return nil +} + +func (a Attach) openMenu(aerc *widgets.Aerc, args []string) error { + filePickerCmd := config.Compose.FilePickerCmd + if filePickerCmd == "" { + return fmt.Errorf("no file-picker-cmd defined") + } + + if strings.Contains(filePickerCmd, "%s") { + verb := "" + if len(args) > 0 { + verb = args[0] + } + filePickerCmd = strings.ReplaceAll(filePickerCmd, "%s", verb) + } + + picks, err := os.CreateTemp("", "aerc-filepicker-*") + if err != nil { + return err + } + + filepicker := exec.Command("sh", "-c", filePickerCmd+" >&3") + filepicker.ExtraFiles = append(filepicker.ExtraFiles, picks) + + t, err := widgets.NewTerminal(filepicker) if err != nil { - aerc.PushError(err.Error()) return err - } else if pathinfo.IsDir() { - aerc.PushError("Attachment must be a file, not a directory") - return nil } + 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) + } + }() + + aerc.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(aerc, f) + if err != nil { + log.Errorf("attach failed for file %s: %v", f, err) + } - composer, _ := aerc.SelectedTab().(*widgets.Composer) - composer.AddAttachment(path) + } + } - aerc.PushSuccess(fmt.Sprintf("Attached %s", pathinfo.Name())) + aerc.AddDialog(widgets.NewDialog( + ui.NewBox(t, "File Picker", "", aerc.SelectedAccountUiConfig()), + // start pos on screen + func(h int) int { + return h / 8 + }, + // dialog height + func(h int) int { + return h - 2*h/8 + }, + )) return nil } diff -Nru aerc-0.11.0/commands/compose/attach-key.go aerc-0.14.0/commands/compose/attach-key.go --- aerc-0.11.0/commands/compose/attach-key.go 2022-07-11 19:25:05.000000000 +0000 +++ aerc-0.14.0/commands/compose/attach-key.go 2023-01-04 15:38:38.000000000 +0000 @@ -25,7 +25,7 @@ return errors.New("Usage: attach-key") } - composer, _ := aerc.SelectedTab().(*widgets.Composer) + composer, _ := aerc.SelectedTabContent().(*widgets.Composer) return composer.SetAttachKey(!composer.AttachKey()) } diff -Nru aerc-0.11.0/commands/compose/cc-bcc.go aerc-0.14.0/commands/compose/cc-bcc.go --- aerc-0.11.0/commands/compose/cc-bcc.go 2022-07-11 19:25:05.000000000 +0000 +++ aerc-0.14.0/commands/compose/cc-bcc.go 2023-01-04 15:38:38.000000000 +0000 @@ -25,7 +25,7 @@ if len(args) > 1 { addrs = strings.Join(args[1:], " ") } - composer, _ := aerc.SelectedTab().(*widgets.Composer) + composer, _ := aerc.SelectedTabContent().(*widgets.Composer) switch args[0] { case "cc": diff -Nru aerc-0.11.0/commands/compose/compose.go aerc-0.14.0/commands/compose/compose.go --- aerc-0.11.0/commands/compose/compose.go 2022-07-11 19:25:05.000000000 +0000 +++ aerc-0.14.0/commands/compose/compose.go 2023-01-04 15:38:38.000000000 +0000 @@ -4,9 +4,7 @@ "git.sr.ht/~rjarry/aerc/commands" ) -var ( - ComposeCommands *commands.Commands -) +var ComposeCommands *commands.Commands func register(cmd commands.Command) { if ComposeCommands == nil { diff -Nru aerc-0.11.0/commands/compose/detach.go aerc-0.14.0/commands/compose/detach.go --- aerc-0.11.0/commands/compose/detach.go 2022-07-11 19:25:05.000000000 +0000 +++ aerc-0.14.0/commands/compose/detach.go 2023-01-04 15:38:38.000000000 +0000 @@ -18,13 +18,13 @@ } func (Detach) Complete(aerc *widgets.Aerc, args []string) []string { - composer, _ := aerc.SelectedTab().(*widgets.Composer) + composer, _ := aerc.SelectedTabContent().(*widgets.Composer) return composer.GetAttachments() } func (Detach) Execute(aerc *widgets.Aerc, args []string) error { var path string - composer, _ := aerc.SelectedTab().(*widgets.Composer) + composer, _ := aerc.SelectedTabContent().(*widgets.Composer) if len(args) > 1 { path = strings.Join(args[1:], " ") diff -Nru aerc-0.11.0/commands/compose/edit.go aerc-0.14.0/commands/compose/edit.go --- aerc-0.11.0/commands/compose/edit.go 2022-07-11 19:25:05.000000000 +0000 +++ aerc-0.14.0/commands/compose/edit.go 2023-01-04 15:38:38.000000000 +0000 @@ -24,7 +24,7 @@ if len(args) != 1 { return errors.New("Usage: edit") } - composer, _ := aerc.SelectedTab().(*widgets.Composer) + composer, _ := aerc.SelectedTabContent().(*widgets.Composer) composer.ShowTerminal() composer.FocusTerminal() return nil diff -Nru aerc-0.11.0/commands/compose/encrypt.go aerc-0.14.0/commands/compose/encrypt.go --- aerc-0.11.0/commands/compose/encrypt.go 2022-07-11 19:25:05.000000000 +0000 +++ aerc-0.14.0/commands/compose/encrypt.go 2023-01-04 15:38:38.000000000 +0000 @@ -25,7 +25,7 @@ return errors.New("Usage: encrypt") } - composer, _ := aerc.SelectedTab().(*widgets.Composer) + composer, _ := aerc.SelectedTabContent().(*widgets.Composer) composer.SetEncrypt(!composer.Encrypt()) return nil diff -Nru aerc-0.11.0/commands/compose/header.go aerc-0.14.0/commands/compose/header.go --- aerc-0.11.0/commands/compose/header.go 2022-07-11 19:25:05.000000000 +0000 +++ aerc-0.14.0/commands/compose/header.go 2023-01-04 15:38:38.000000000 +0000 @@ -12,17 +12,15 @@ type Header struct{} -var ( - headers = []string{ - "From", - "To", - "Cc", - "Bcc", - "Subject", - "Comments", - "Keywords", - } -) +var headers = []string{ + "From", + "To", + "Cc", + "Bcc", + "Subject", + "Comments", + "Keywords", +} func init() { register(Header{}) @@ -50,17 +48,14 @@ return errors.New("command parsing failed") } - var ( - force bool = false - ) + var force bool = false for _, opt := range opts { - switch opt.Option { - case 'f': + if opt.Option == 'f' { force = true } } - composer, _ := aerc.SelectedTab().(*widgets.Composer) + composer, _ := aerc.SelectedTabContent().(*widgets.Composer) args[optind] = strings.TrimRight(args[optind], ":") diff -Nru aerc-0.11.0/commands/compose/multipart.go aerc-0.14.0/commands/compose/multipart.go --- aerc-0.11.0/commands/compose/multipart.go 1970-01-01 00:00:00.000000000 +0000 +++ aerc-0.14.0/commands/compose/multipart.go 2023-01-04 15:38:38.000000000 +0000 @@ -0,0 +1,74 @@ +package compose + +import ( + "bytes" + "fmt" + + "git.sr.ht/~rjarry/aerc/commands" + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/widgets" + "git.sr.ht/~sircmpwn/getopt" +) + +type Multipart struct{} + +func init() { + register(Multipart{}) +} + +func (Multipart) Aliases() []string { + return []string{"multipart"} +} + +func (Multipart) Complete(aerc *widgets.Aerc, args []string) []string { + var completions []string + completions = append(completions, "-d") + for mime := range config.Converters { + completions = append(completions, mime) + } + return commands.CompletionFromList(aerc, completions, args) +} + +func (a Multipart) Execute(aerc *widgets.Aerc, args []string) error { + composer, ok := aerc.SelectedTabContent().(*widgets.Composer) + if !ok { + return fmt.Errorf(":multipart is only available on the compose::review screen") + } + + opts, optind, err := getopt.Getopts(args, "d") + if err != nil { + return fmt.Errorf("Usage: :multipart [-d] ") + } + var remove bool = false + for _, opt := range opts { + if opt.Option == 'd' { + remove = true + } + } + args = args[optind:] + if len(args) != 1 { + return fmt.Errorf("Usage: :multipart [-d] ") + } + mime := args[0] + + if remove { + return composer.RemovePart(mime) + } else { + _, found := config.Converters[mime] + if !found { + return fmt.Errorf("no command defined for MIME type: %s", mime) + } + err = composer.AppendPart( + 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 + bytes.NewReader([]byte{}), + ) + if err != nil { + return err + } + } + + return nil +} diff -Nru aerc-0.11.0/commands/compose/next-field.go aerc-0.14.0/commands/compose/next-field.go --- aerc-0.11.0/commands/compose/next-field.go 2022-07-11 19:25:05.000000000 +0000 +++ aerc-0.14.0/commands/compose/next-field.go 2023-01-04 15:38:38.000000000 +0000 @@ -24,7 +24,7 @@ if len(args) > 2 { return nextPrevFieldUsage(args[0]) } - composer, _ := aerc.SelectedTab().(*widgets.Composer) + composer, _ := aerc.SelectedTabContent().(*widgets.Composer) if args[0] == "prev-field" { composer.PrevField() } else { diff -Nru aerc-0.11.0/commands/compose/postpone.go aerc-0.14.0/commands/compose/postpone.go --- aerc-0.11.0/commands/compose/postpone.go 2022-07-11 19:25:05.000000000 +0000 +++ aerc-0.14.0/commands/compose/postpone.go 2023-01-04 15:38:38.000000000 +0000 @@ -7,7 +7,7 @@ "github.com/miolini/datacounter" "github.com/pkg/errors" - "git.sr.ht/~rjarry/aerc/logging" + "git.sr.ht/~rjarry/aerc/log" "git.sr.ht/~rjarry/aerc/models" "git.sr.ht/~rjarry/aerc/widgets" "git.sr.ht/~rjarry/aerc/worker/types" @@ -35,15 +35,19 @@ if acct == nil { return errors.New("No account selected") } - composer, _ := aerc.SelectedTab().(*widgets.Composer) + tab := aerc.SelectedTab() + if tab == nil { + return errors.New("No tab selected") + } + composer, _ := tab.Content.(*widgets.Composer) config := composer.Config() - tabName := aerc.TabNames()[aerc.SelectedTabIndex()] + tabName := tab.Name if config.Postpone == "" { return errors.New("No Postpone location configured") } - aerc.Logger().Println("Postponing mail") + log.Tracef("Postponing mail") header, err := composer.PrepareHeader() if err != nil { @@ -66,7 +70,7 @@ // run this as a goroutine so we can make other progress. The message // will be saved once the directory is created. go func() { - defer logging.PanicHandler() + defer log.PanicHandler() errStr := <-errChan if errStr != "" { @@ -76,7 +80,7 @@ handleErr := func(err error) { aerc.PushError(err.Error()) - aerc.Logger().Println("Postponing failed:", err) + log.Errorf("Postponing failed: %v", err) aerc.NewTab(composer, tabName) } diff -Nru aerc-0.11.0/commands/compose/send.go aerc-0.14.0/commands/compose/send.go --- aerc-0.11.0/commands/compose/send.go 2022-07-11 19:25:05.000000000 +0000 +++ aerc-0.14.0/commands/compose/send.go 2023-01-04 15:38:38.000000000 +0000 @@ -15,8 +15,9 @@ "github.com/google/shlex" "github.com/pkg/errors" + "git.sr.ht/~rjarry/aerc/commands/mode" "git.sr.ht/~rjarry/aerc/lib" - "git.sr.ht/~rjarry/aerc/logging" + "git.sr.ht/~rjarry/aerc/log" "git.sr.ht/~rjarry/aerc/models" "git.sr.ht/~rjarry/aerc/widgets" "git.sr.ht/~rjarry/aerc/worker/types" @@ -42,11 +43,19 @@ if len(args) > 1 { return errors.New("Usage: send") } - composer, _ := aerc.SelectedTab().(*widgets.Composer) - tabName := aerc.TabNames()[aerc.SelectedTabIndex()] + tab := aerc.SelectedTab() + if tab == nil { + return errors.New("No selected tab") + } + composer, _ := tab.Content.(*widgets.Composer) + tabName := tab.Name config := composer.Config() - if config.Outgoing == "" { + 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") } @@ -69,7 +78,7 @@ return errors.Wrap(err, "ParseAddress(config.From)") } - uri, err := url.Parse(config.Outgoing) + uri, err := url.Parse(outgoing) if err != nil { return errors.Wrap(err, "url.Parse(outgoing)") } @@ -91,19 +100,58 @@ rcpts: rcpts, } + warn, err := composer.ShouldWarnAttachment() + if err != nil || warn { + msg := "You may have forgotten an attachment." + if err != nil { + log.Warnf("failed to check for a forgotten attachment: %v", err) + msg = "Failed to check for a forgotten attachment." + } + + prompt := widgets.NewPrompt( + msg+" Abort send? [Y/n] ", + func(text string) { + if text == "n" || text == "N" { + send(aerc, composer, ctx, header, tabName) + } + }, func(cmd string) ([]string, string) { + if cmd == "" { + return []string{"y", "n"}, "" + } + + return nil, "" + }, + ) + + aerc.PushPrompt(prompt) + } else { + send(aerc, composer, ctx, header, tabName) + } + + return nil +} + +func send(aerc *widgets.Aerc, composer *widgets.Composer, ctx sendCtx, + header *mail.Header, tabName string, +) { // 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 aerc.RemoveTab(composer) aerc.PushStatus("Sending...", 10*time.Second) + // enter no-quit mode + mode.NoQuit() + var copyBuf bytes.Buffer // for the Sent folder content if CopyTo is set + config := composer.Config() failCh := make(chan error) - //writer + // writer go func() { - defer logging.PanicHandler() + defer log.PanicHandler() var sender io.WriteCloser + var err error switch ctx.scheme { case "smtp": fallthrough @@ -132,11 +180,14 @@ failCh <- sender.Close() }() - //cleanup + copy to sent + // cleanup + copy to sent go func() { - defer logging.PanicHandler() + defer log.PanicHandler() + + // leave no-quit mode + defer mode.NoQuitDone() - err = <-failCh + err := <-failCh if err != nil { aerc.PushError(strings.ReplaceAll(err.Error(), "\n", " ")) aerc.NewTab(composer, tabName) @@ -161,7 +212,6 @@ composer.SetSent() composer.Close() }() - return nil } func listRecipients(h *mail.Header) ([]*mail.Address, error) { @@ -235,12 +285,13 @@ auth = "plain" if uri.Scheme != "" { parts := strings.Split(uri.Scheme, "+") - if len(parts) == 1 { + switch len(parts) { + case 1: scheme = parts[0] - } else if len(parts) == 2 { + case 2: scheme = parts[0] auth = parts[1] - } else { + default: return "", "", fmt.Errorf("Unknown transfer protocol %s", uri.Scheme) } } @@ -274,18 +325,39 @@ OAuth2: oauth2, Enabled: true, } - if bearer.OAuth2.Endpoint.TokenURL == "" { - return nil, fmt.Errorf("No 'TokenURL' configured for this account") - } - token, err := bearer.ExchangeRefreshToken(password) - if err != nil { - return nil, err + if bearer.OAuth2.Endpoint.TokenURL != "" { + token, err := bearer.ExchangeRefreshToken(password) + if err != nil { + return nil, err + } + password = token.AccessToken } - password = token.AccessToken saslClient = sasl.NewOAuthBearerClient(&sasl.OAuthBearerOptions{ Username: uri.User.Username(), Token: password, }) + case "xoauth2": + q := uri.Query() + oauth2 := &oauth2.Config{} + if q.Get("token_endpoint") != "" { + oauth2.ClientID = q.Get("client_id") + oauth2.ClientSecret = q.Get("client_secret") + oauth2.Scopes = []string{q.Get("scope")} + oauth2.Endpoint.TokenURL = q.Get("token_endpoint") + } + password, _ := uri.User.Password() + bearer := lib.Xoauth2{ + OAuth2: oauth2, + Enabled: true, + } + if bearer.OAuth2.Endpoint.TokenURL != "" { + token, err := bearer.ExchangeRefreshToken(password) + if err != nil { + return nil, err + } + password = token.AccessToken + } + saslClient = lib.NewXoauth2Client(uri.User.Username(), password) default: return nil, fmt.Errorf("Unsupported auth mechanism %s", auth) } @@ -365,7 +437,7 @@ func connectSmtp(starttls bool, host string) (*smtp.Client, error) { serverName := host if !strings.ContainsRune(host, ':') { - host = host + ":587" // Default to submission port + host += ":587" // Default to submission port } else { serverName = host[:strings.IndexRune(host, ':')] } @@ -387,14 +459,12 @@ conn.Close() return nil, errors.Wrap(err, "StartTLS") } - } else { - if starttls { - err := errors.New("STARTTLS requested, but not supported " + - "by this SMTP server. Is someone tampering with your " + - "connection?") - conn.Close() - return nil, err - } + } else if starttls { + err := errors.New("STARTTLS requested, but not supported " + + "by this SMTP server. Is someone tampering with your " + + "connection?") + conn.Close() + return nil, err } return conn, nil } @@ -402,7 +472,7 @@ func connectSmtps(host string) (*smtp.Client, error) { serverName := host if !strings.ContainsRune(host, ':') { - host = host + ":465" // Default to smtps port + host += ":465" // Default to smtps port } else { serverName = host[:strings.IndexRune(host, ':')] } @@ -416,7 +486,8 @@ } func copyToSent(worker *types.Worker, dest string, - n int, msg io.Reader) <-chan error { + n int, msg io.Reader, +) <-chan error { errCh := make(chan error) worker.PostAction(&types.AppendMessage{ Destination: dest, diff -Nru aerc-0.11.0/commands/compose/sign.go aerc-0.14.0/commands/compose/sign.go --- aerc-0.11.0/commands/compose/sign.go 2022-07-11 19:25:05.000000000 +0000 +++ aerc-0.14.0/commands/compose/sign.go 2023-01-04 15:38:38.000000000 +0000 @@ -26,7 +26,7 @@ return errors.New("Usage: sign") } - composer, _ := aerc.SelectedTab().(*widgets.Composer) + composer, _ := aerc.SelectedTabContent().(*widgets.Composer) err := composer.SetSign(!composer.Sign()) if err != nil { diff -Nru aerc-0.11.0/commands/compose/switch.go aerc-0.14.0/commands/compose/switch.go --- aerc-0.11.0/commands/compose/switch.go 1970-01-01 00:00:00.000000000 +0000 +++ aerc-0.14.0/commands/compose/switch.go 2023-01-04 15:38:38.000000000 +0000 @@ -0,0 +1,79 @@ +package compose + +import ( + "errors" + "fmt" + + "git.sr.ht/~rjarry/aerc/widgets" + "git.sr.ht/~sircmpwn/getopt" +) + +type AccountSwitcher interface { + SwitchAccount(*widgets.AccountView) error +} + +type SwitchAccount struct{} + +func init() { + register(SwitchAccount{}) +} + +func (SwitchAccount) Aliases() []string { + return []string{"switch-account"} +} + +func (SwitchAccount) Complete(aerc *widgets.Aerc, args []string) []string { + return aerc.AccountNames() +} + +func (SwitchAccount) Execute(aerc *widgets.Aerc, args []string) error { + opts, optind, err := getopt.Getopts(args, "np") + if err != nil { + return err + } + var next, prev bool + for _, opt := range opts { + switch opt.Option { + case 'n': + next = true + prev = false + case 'p': + next = false + prev = true + } + } + posargs := args[optind:] + // NOT ((prev || next) XOR (len(posargs) == 1)) + if (prev || next) == (len(posargs) == 1) { + name := "" + if acct := aerc.SelectedAccount(); acct != nil { + name = fmt.Sprintf("Current account: %s. ", acct.Name()) + } + return errors.New(name + "Usage: switch-account [-np] ") + } + + switcher, ok := aerc.SelectedTabContent().(AccountSwitcher) + if !ok { + return errors.New("this tab cannot switch accounts") + } + + var acct *widgets.AccountView + + switch { + case prev: + acct, err = aerc.PrevAccount() + case next: + acct, err = aerc.NextAccount() + default: + acct, err = aerc.Account(posargs[0]) + } + if err != nil { + return err + } + if err = switcher.SwitchAccount(acct); err != nil { + return err + } + acct.UpdateStatus() + + return nil +} diff -Nru aerc-0.11.0/commands/ct.go aerc-0.14.0/commands/ct.go --- aerc-0.11.0/commands/ct.go 2022-07-11 19:25:05.000000000 +0000 +++ aerc-0.14.0/commands/ct.go 2023-01-04 15:38:38.000000000 +0000 @@ -40,15 +40,16 @@ } else { n, err := strconv.Atoi(joinedArgs) if err == nil { - if strings.HasPrefix(joinedArgs, "+") { + switch { + case strings.HasPrefix(joinedArgs, "+"): for ; n > 0; n-- { aerc.NextTab() } - } else if strings.HasPrefix(joinedArgs, "-") { + case strings.HasPrefix(joinedArgs, "-"): for ; n < 0; n++ { aerc.PrevTab() } - } else { + default: ok := aerc.SelectTabIndex(n) if !ok { return errors.New( diff -Nru aerc-0.11.0/commands/eml.go aerc-0.14.0/commands/eml.go --- aerc-0.11.0/commands/eml.go 1970-01-01 00:00:00.000000000 +0000 +++ aerc-0.14.0/commands/eml.go 2023-01-04 15:38:38.000000000 +0000 @@ -0,0 +1,83 @@ +package commands + +import ( + "bytes" + "fmt" + "io" + "os" + "strings" + + "git.sr.ht/~rjarry/aerc/lib" + "git.sr.ht/~rjarry/aerc/widgets" +) + +type Eml struct{} + +func init() { + register(Eml{}) +} + +func (Eml) Aliases() []string { + return []string{"eml", "preview"} +} + +func (Eml) Complete(aerc *widgets.Aerc, args []string) []string { + return CompletePath(strings.Join(args, " ")) +} + +func (Eml) Execute(aerc *widgets.Aerc, args []string) error { + acct := aerc.SelectedAccount() + if acct == nil { + return fmt.Errorf("no account selected") + } + + showEml := func(r io.Reader) { + data, err := io.ReadAll(r) + if err != nil { + aerc.PushError(err.Error()) + return + } + lib.NewEmlMessageView(data, aerc.Crypto, aerc.DecryptKeys, + func(view lib.MessageView, err error) { + if err != nil { + aerc.PushError(err.Error()) + return + } + msgView := widgets.NewMessageViewer(acct, view) + aerc.NewTab(msgView, + view.MessageInfo().Envelope.Subject) + }) + } + + if len(args) == 1 { + switch tab := aerc.SelectedTabContent().(type) { + case *widgets.MessageViewer: + part := tab.SelectedMessagePart() + tab.MessageView().FetchBodyPart(part.Index, showEml) + case *widgets.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 { + path := strings.Join(args[1:], " ") + if _, err := os.Stat(path); err != nil { + return err + } + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + showEml(f) + } + return nil +} diff -Nru aerc-0.11.0/commands/exec.go aerc-0.14.0/commands/exec.go --- aerc-0.11.0/commands/exec.go 2022-07-11 19:25:05.000000000 +0000 +++ aerc-0.14.0/commands/exec.go 2023-01-04 15:38:38.000000000 +0000 @@ -7,7 +7,7 @@ "os/exec" "time" - "git.sr.ht/~rjarry/aerc/logging" + "git.sr.ht/~rjarry/aerc/log" "git.sr.ht/~rjarry/aerc/widgets" ) @@ -33,7 +33,7 @@ cmd := exec.Command(args[1], args[2:]...) env := os.Environ() - switch view := aerc.SelectedTab().(type) { + switch view := aerc.SelectedTabContent().(type) { case *widgets.AccountView: env = append(env, fmt.Sprintf("account=%s", view.AccountConfig().Name)) env = append(env, fmt.Sprintf("folder=%s", view.Directories().Selected())) @@ -46,7 +46,7 @@ cmd.Env = env go func() { - defer logging.PanicHandler() + defer log.PanicHandler() err := cmd.Run() if err != nil { diff -Nru aerc-0.11.0/commands/global.go aerc-0.14.0/commands/global.go --- aerc-0.11.0/commands/global.go 2022-07-11 19:25:05.000000000 +0000 +++ aerc-0.14.0/commands/global.go 2023-01-04 15:38:38.000000000 +0000 @@ -1,8 +1,6 @@ package commands -var ( - GlobalCommands *Commands -) +var GlobalCommands *Commands func register(cmd Command) { if GlobalCommands == nil { diff -Nru aerc-0.11.0/commands/help.go aerc-0.14.0/commands/help.go --- aerc-0.11.0/commands/help.go 2022-07-11 19:25:05.000000000 +0000 +++ aerc-0.14.0/commands/help.go 2023-01-04 15:38:38.000000000 +0000 @@ -10,6 +10,8 @@ var pages = []string{ "aerc", + "accounts", + "binds", "config", "imap", "notmuch", @@ -19,6 +21,7 @@ "stylesets", "templates", "tutorial", + "keys", } func init() { @@ -40,5 +43,22 @@ } else if len(args) > 2 { return errors.New("Usage: help [topic]") } + + if page == "aerc-keys" { + aerc.AddDialog(widgets.NewDialog( + widgets.NewListBox( + "Bindings: Press or to close. "+ + "Start typing to filter bindings.", + aerc.HumanReadableBindings(), + aerc.SelectedAccountUiConfig(), + func(_ string) { + aerc.CloseDialog() + }, + ), + func(h int) int { return h / 4 }, + func(h int) int { return h / 2 }, + )) + } + return TermCore(aerc, []string{"term", "man", page}) } diff -Nru aerc-0.11.0/commands/history.go aerc-0.14.0/commands/history.go --- aerc-0.11.0/commands/history.go 2022-07-11 19:25:05.000000000 +0000 +++ aerc-0.14.0/commands/history.go 2023-01-04 15:38:38.000000000 +0000 @@ -1,5 +1,18 @@ package commands +import ( + "bufio" + "bytes" + "fmt" + "io" + "os" + "path" + "sync" + + "git.sr.ht/~rjarry/aerc/log" + "github.com/kyoh86/xdg" +) + type cmdHistory struct { // rolling buffer of prior commands // @@ -9,6 +22,10 @@ // current placement in list current int + + // initialize history storage + initHistfile sync.Once + histfile io.ReadWriter } // number of commands to keep in history @@ -18,12 +35,18 @@ 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:] } - h.cmdList = append(h.cmdList, cmd) + 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 @@ -34,6 +57,8 @@ // 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)" @@ -47,6 +72,8 @@ // 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)" @@ -60,3 +87,55 @@ func (h *cmdHistory) Reset() { h.current = len(h.cmdList) } + +func (h *cmdHistory) initialize() { + var err error + openFlags := os.O_RDWR | os.O_EXCL + + histPath := path.Join(xdg.CacheHome(), "aerc", "history") + if _, err := os.Stat(histPath); os.IsNotExist(err) { + _ = os.MkdirAll(path.Join(xdg.CacheHome(), "aerc"), 0o700) // caught by OpenFile + openFlags |= os.O_CREATE + } + + // O_EXCL to make sure that only one aerc writes to the file + h.histfile, err = os.OpenFile( + histPath, + openFlags, + 0o600, + ) + if err != nil { + log.Errorf("failed to open history file: %v", err) + // basically mirror the old behavior + h.histfile = bytes.NewBuffer([]byte{}) + return + } + + s := bufio.NewScanner(h.histfile) + + for s.Scan() { + h.cmdList = append(h.cmdList, s.Text()) + } + + h.Reset() +} + +func (h *cmdHistory) writeHistory() { + if fh, ok := h.histfile.(*os.File); ok { + err := fh.Truncate(0) + if err != nil { + // if we can't delete it, don't break it. + return + } + _, err = fh.Seek(0, io.SeekStart) + if err != nil { + // if we can't delete it, don't break it. + return + } + for _, entry := range h.cmdList { + fmt.Fprintln(fh, entry) + } + + fh.Sync() //nolint:errcheck // if your computer can't sync you're in bigger trouble + } +} diff -Nru aerc-0.11.0/commands/mode/noquit.go aerc-0.14.0/commands/mode/noquit.go --- aerc-0.11.0/commands/mode/noquit.go 1970-01-01 00:00:00.000000000 +0000 +++ aerc-0.14.0/commands/mode/noquit.go 2023-01-04 15:38:38.000000000 +0000 @@ -0,0 +1,23 @@ +package mode + +import "sync/atomic" + +// noquit is a counter for goroutines that requested the no-quit mode +var noquit int32 + +// NoQuit enters no-quit mode where aerc cannot be exited (unless the force +// option is used) +func NoQuit() { + atomic.AddInt32(&noquit, 1) +} + +// NoQuitDone leaves the no-quit mode +func NoQuitDone() { + atomic.AddInt32(&noquit, -1) +} + +// QuitAllowed checks if aerc can exit normally (only when all goroutines that +// requested a no-quit mode were done and called the NoQuitDone() function) +func QuitAllowed() bool { + return atomic.LoadInt32(&noquit) <= 0 +} diff -Nru aerc-0.11.0/commands/move-tab.go aerc-0.14.0/commands/move-tab.go --- aerc-0.11.0/commands/move-tab.go 2022-07-11 19:25:05.000000000 +0000 +++ aerc-0.14.0/commands/move-tab.go 2023-01-04 15:38:38.000000000 +0000 @@ -31,21 +31,14 @@ n, err := strconv.Atoi(joinedArgs) if err != nil { - return fmt.Errorf("failed to parse index argument: %v", err) + return fmt.Errorf("failed to parse index argument: %w", err) } - i := aerc.SelectedTabIndex() - l := aerc.NumTabs() - - if strings.HasPrefix(joinedArgs, "+") { - i = (i + n) % l - } else if strings.HasPrefix(joinedArgs, "-") { - i = (((i + n) % l) + l) % l - } else { - i = n + var relative bool + if strings.HasPrefix(joinedArgs, "+") || strings.HasPrefix(joinedArgs, "-") { + relative = true } - - aerc.MoveTab(i) + aerc.MoveTab(n, relative) return nil } diff -Nru aerc-0.11.0/commands/msg/archive.go aerc-0.14.0/commands/msg/archive.go --- aerc-0.11.0/commands/msg/archive.go 2022-07-11 19:25:05.000000000 +0000 +++ aerc-0.14.0/commands/msg/archive.go 2023-01-04 15:38:38.000000000 +0000 @@ -5,10 +5,9 @@ "fmt" "path" "sync" - "time" "git.sr.ht/~rjarry/aerc/commands" - "git.sr.ht/~rjarry/aerc/logging" + "git.sr.ht/~rjarry/aerc/log" "git.sr.ht/~rjarry/aerc/models" "git.sr.ht/~rjarry/aerc/widgets" "git.sr.ht/~rjarry/aerc/worker/types" @@ -53,8 +52,13 @@ return err } archiveDir := acct.AccountConfig().Archive - store.Next() - acct.Messages().Invalidate() + var uids []uint32 + for _, msg := range msgs { + uids = append(uids, msg.Uid) + } + marker := store.Marker() + marker.ClearVisualMark() + next := findNextNonDeleted(uids, store) var uidMap map[string][]uint32 switch args[1] { @@ -82,7 +86,8 @@ for dir, uids := range uidMap { store.Move(uids, dir, true, func( - msg types.WorkerMessage) { + msg types.WorkerMessage, + ) { switch msg := msg.(type) { case *types.Done: wg.Done() @@ -90,23 +95,25 @@ aerc.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 logging.PanicHandler() + defer log.PanicHandler() wg.Wait() if success { - aerc.PushStatus("Messages archived.", 10*time.Second) + handleDone(aerc, acct, next, "Messages archived.", store) } }() return nil } func groupBy(msgs []*models.MessageInfo, - grouper func(*models.MessageInfo) string) map[string][]uint32 { + grouper func(*models.MessageInfo) string, +) map[string][]uint32 { m := make(map[string][]uint32) for _, msg := range msgs { group := grouper(msg) diff -Nru aerc-0.11.0/commands/msg/copy.go aerc-0.14.0/commands/msg/copy.go --- aerc-0.11.0/commands/msg/copy.go 2022-07-11 19:25:05.000000000 +0000 +++ aerc-0.14.0/commands/msg/copy.go 2023-01-04 15:38:38.000000000 +0000 @@ -34,12 +34,9 @@ if err != nil { return err } - var ( - createParents bool - ) + var createParents bool for _, opt := range opts { - switch opt.Option { - case 'p': + if opt.Option == 'p' { createParents = true } } @@ -54,12 +51,12 @@ } store.Copy(uids, strings.Join(args[optind:], " "), createParents, func( - msg types.WorkerMessage) { - + msg types.WorkerMessage, + ) { switch msg := msg.(type) { case *types.Done: aerc.PushStatus("Messages copied.", 10*time.Second) - store.ClearVisualMark() + store.Marker().ClearVisualMark() case *types.Error: aerc.PushError(msg.Error.Error()) } diff -Nru aerc-0.11.0/commands/msg/delete.go aerc-0.14.0/commands/msg/delete.go --- aerc-0.11.0/commands/msg/delete.go 2022-07-11 19:25:05.000000000 +0000 +++ aerc-0.14.0/commands/msg/delete.go 2023-01-04 15:38:38.000000000 +0000 @@ -4,7 +4,9 @@ "errors" "time" + "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/widgets" "git.sr.ht/~rjarry/aerc/worker/types" @@ -42,58 +44,84 @@ if err != nil { return err } + sel := store.Selected() + marker := store.Marker() + marker.ClearVisualMark() + // caution, can be nil + next := findNextNonDeleted(uids, store) store.Delete(uids, func(msg types.WorkerMessage) { switch msg := msg.(type) { case *types.Done: aerc.PushStatus("Messages deleted.", 10*time.Second) + mv, isMsgView := h.msgProvider.(*widgets.MessageViewer) + if isMsgView { + if !config.Ui.NextMessageOnDelete { + aerc.RemoveTab(h.msgProvider) + } else { + // no more messages in the list + if next == nil { + aerc.RemoveTab(h.msgProvider) + acct.Messages().Select(-1) + ui.Invalidate() + return + } + lib.NewMessageStoreView(next, mv.MessageView().SeenFlagSet(), + store, aerc.Crypto, aerc.DecryptKeys, + func(view lib.MessageView, err error) { + if err != nil { + aerc.PushError(err.Error()) + return + } + nextMv := widgets.NewMessageViewer(acct, view) + aerc.ReplaceTab(mv, nextMv, next.Envelope.Subject) + }) + } + } 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) aerc.PushError(msg.Error.Error()) case *types.Unsupported: + marker.Remark() + store.Select(sel.Uid) // notmuch doesn't support it, we want the user to know aerc.PushError(" error, unsupported for this worker") } }) - - //caution, can be nil - next := findNextNonDeleted(uids, store) - - mv, isMsgView := h.msgProvider.(*widgets.MessageViewer) - if isMsgView { - if !aerc.Config().Ui.NextMessageOnDelete { - aerc.RemoveTab(h.msgProvider) - } else { - // no more messages in the list - if next == nil { - aerc.RemoveTab(h.msgProvider) - acct.Messages().Invalidate() - return nil - } - lib.NewMessageStoreView(next, store, aerc.Crypto, aerc.DecryptKeys, - func(view lib.MessageView, err error) { - if err != nil { - aerc.PushError(err.Error()) - return - } - nextMv := widgets.NewMessageViewer(acct, aerc.Config(), view) - aerc.ReplaceTab(mv, nextMv, next.Envelope.Subject) - }) - } - } - acct.Messages().Invalidate() return nil } func findNextNonDeleted(deleted []uint32, store *lib.MessageStore) *models.MessageInfo { - selected := store.Selected() - if !contains(deleted, selected.Uid) { - return selected + 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 + } } - store.Next() - next := store.Selected() - if next == selected || next == nil { - // the last message is in the deleted state or doesn't exist - return nil + if next != nil { + store.Select(next.Uid) } return next } diff -Nru aerc-0.11.0/commands/msg/envelope.go aerc-0.14.0/commands/msg/envelope.go --- aerc-0.11.0/commands/msg/envelope.go 1970-01-01 00:00:00.000000000 +0000 +++ aerc-0.14.0/commands/msg/envelope.go 2023-01-04 15:38:38.000000000 +0000 @@ -0,0 +1,152 @@ +package msg + +import ( + "errors" + "fmt" + "strings" + + "git.sr.ht/~rjarry/aerc/lib/format" + "git.sr.ht/~rjarry/aerc/log" + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/widgets" + "git.sr.ht/~sircmpwn/getopt" + "github.com/emersion/go-message/mail" +) + +type Envelope struct{} + +func init() { + register(Envelope{}) +} + +func (Envelope) Aliases() []string { + return []string{"envelope"} +} + +func (Envelope) Complete(aerc *widgets.Aerc, args []string) []string { + return nil +} + +func (Envelope) Execute(aerc *widgets.Aerc, args []string) error { + header := false + fmtStr := "%-20.20s: %s" + opts, _, err := getopt.Getopts(args, "hs:") + if err != nil { + return err + } + for _, opt := range opts { + switch opt.Option { + case 's': + fmtStr = opt.Value + case 'h': + header = true + } + } + + acct := aerc.SelectedAccount() + if acct == nil { + return errors.New("No account selected") + } + + var list []string + if msg, err := acct.SelectedMessage(); err != nil { + return err + } else { + if msg != nil { + if header { + list = parseHeader(msg, fmtStr) + } else { + list = parseEnvelope(msg, fmtStr, + acct.UiConfig().TimestampFormat) + } + } else { + return fmt.Errorf("Selected message is empty.") + } + } + + n := len(list) + aerc.AddDialog(widgets.NewDialog( + widgets.NewListBox( + "Message Envelope. Press or to close. "+ + "Start typing to filter.", + list, + aerc.SelectedAccountUiConfig(), + func(_ string) { + aerc.CloseDialog() + }, + ), + // start pos on screen + func(h int) int { + if n < h/8*6 { + return h/2 - n/2 - 1 + } + return h / 8 + }, + // dialog height + func(h int) int { + if n < h/8*6 { + return n + 2 + } + return h / 8 * 6 + }, + )) + + return nil +} + +func parseEnvelope(msg *models.MessageInfo, fmtStr, fmtTime string, +) (result []string) { + if envlp := msg.Envelope; envlp != nil { + addStr := func(key, text string) { + result = append(result, fmt.Sprintf(fmtStr, key, text)) + } + addAddr := func(key string, ls []*mail.Address) { + for _, l := range ls { + result = append(result, + fmt.Sprintf(fmtStr, key, + format.AddressForHumans(l))) + } + } + + addStr("Date", envlp.Date.Format(fmtTime)) + addStr("Subject", envlp.Subject) + addStr("Message-Id", envlp.MessageId) + + addAddr("From", envlp.From) + addAddr("To", envlp.To) + addAddr("ReplyTo", envlp.ReplyTo) + addAddr("Cc", envlp.Cc) + addAddr("Bcc", envlp.Bcc) + } + return +} + +func parseHeader(msg *models.MessageInfo, fmtStr string) (result []string) { + if h := msg.RFC822Headers; h != nil { + hf := h.Fields() + for hf.Next() { + text, err := hf.Text() + if err != nil { + log.Errorf(err.Error()) + text = hf.Value() + } + result = append(result, + headerExpand(fmtStr, hf.Key(), text)...) + } + } + return +} + +func headerExpand(fmtStr, key, text string) []string { + var result []string + switch strings.ToLower(key) { + case "to", "from", "bcc", "cc": + for _, item := range strings.Split(text, ",") { + result = append(result, fmt.Sprintf(fmtStr, key, + strings.TrimSpace(item))) + } + default: + result = append(result, fmt.Sprintf(fmtStr, key, text)) + } + return result +} diff -Nru aerc-0.11.0/commands/msg/forward.go aerc-0.14.0/commands/msg/forward.go --- aerc-0.11.0/commands/msg/forward.go 2022-07-11 19:25:05.000000000 +0000 +++ aerc-0.14.0/commands/msg/forward.go 2023-01-04 15:38:38.000000000 +0000 @@ -6,15 +6,17 @@ "errors" "fmt" "io" - "io/ioutil" "math/rand" "os" "path" "strings" "sync" + "git.sr.ht/~rjarry/aerc/config" "git.sr.ht/~rjarry/aerc/lib" "git.sr.ht/~rjarry/aerc/lib/format" + "git.sr.ht/~rjarry/aerc/lib/ui" + "git.sr.ht/~rjarry/aerc/log" "git.sr.ht/~rjarry/aerc/models" "git.sr.ht/~rjarry/aerc/widgets" "git.sr.ht/~rjarry/aerc/worker/types" @@ -60,7 +62,7 @@ return errors.New("Options -A and -F are mutually exclusive") } - widget := aerc.SelectedTab().(widgets.ProvidesMessage) + widget := aerc.SelectedTabContent().(widgets.ProvidesMessage) acct := widget.SelectedAccount() if acct == nil { return errors.New("No account selected") @@ -73,7 +75,7 @@ if err != nil { return err } - acct.Logger().Println("Forwarding email " + msg.Envelope.MessageId) + log.Debugf("Forwarding email <%s>", msg.Envelope.MessageId) h := &mail.Header{} subject := "Fwd: " + msg.Envelope.Subject @@ -84,7 +86,7 @@ if strings.Contains(to, "@") { tolist, err = mail.ParseAddressList(to) if err != nil { - return fmt.Errorf("invalid to address(es): %v", err) + return fmt.Errorf("invalid to address(es): %w", err) } } if len(tolist) > 0 { @@ -98,7 +100,7 @@ } addTab := func() (*widgets.Composer, error) { - composer, err := widgets.NewComposer(aerc, acct, aerc.Config(), + composer, err := widgets.NewComposer(aerc, acct, acct.AccountConfig(), acct.Worker(), template, h, original) if err != nil { aerc.PushError("Error: " + err.Error()) @@ -117,13 +119,13 @@ } else { tab.Name = subject } - tab.Content.Invalidate() + ui.Invalidate() }) return composer, nil } if attachFull { - tmpDir, err := ioutil.TempDir("", "aerc-tmp-attachment") + tmpDir, err := os.MkdirTemp("", "aerc-tmp-attachment") if err != nil { return err } @@ -132,14 +134,20 @@ store.FetchFull([]uint32{msg.Uid}, func(fm *types.FullMessage) { tmpFile, err := os.Create(tmpFileName) if err != nil { - println(err) - // TODO: Do something with the error - addTab() + log.Warnf("failed to create temporary attachment: %v", err) + _, err = addTab() + if err != nil { + log.Warnf("failed to add tab: %v", err) + } return } defer tmpFile.Close() - io.Copy(tmpFile, fm.Content.Reader) + _, err = io.Copy(tmpFile, fm.Content.Reader) + if err != nil { + log.Warnf("failed to write to tmpfile: %v", err) + return + } composer, err := addTab() if err != nil { return @@ -151,7 +159,7 @@ }) } else { if template == "" { - template = aerc.Config().Templates.Forwards + template = config.Templates.Forwards } part := lib.FindPlaintext(msg.BodyStructure, nil) @@ -187,18 +195,23 @@ } bs, err := msg.BodyStructure.PartAtIndex(p) if err != nil { - acct.Logger().Println("forward: PartAtIndex:", err) + log.Errorf("cannot get PartAtIndex %v: %v", p, err) continue } store.FetchBodyPart(msg.Uid, p, func(reader io.Reader) { - mime := fmt.Sprintf("%s/%s", bs.MIMEType, bs.MIMESubType) - name, ok := bs.Params["name"] + mime := bs.FullMIMEType() + params := lib.SetUtf8Charset(bs.Params) + name, ok := params["name"] if !ok { name = fmt.Sprintf("%s_%s_%d", bs.MIMEType, bs.MIMESubType, rand.Uint64()) } mu.Lock() - composer.AddPartAttachment(name, mime, bs.Params, reader) + err := composer.AddPartAttachment(name, mime, params, reader) mu.Unlock() + if err != nil { + log.Errorf(err.Error()) + aerc.PushError(err.Error()) + } }) } } diff -Nru aerc-0.11.0/commands/msg/invite.go aerc-0.14.0/commands/msg/invite.go --- aerc-0.11.0/commands/msg/invite.go 2022-07-11 19:25:05.000000000 +0000 +++ aerc-0.14.0/commands/msg/invite.go 2023-01-04 15:38:38.000000000 +0000 @@ -5,9 +5,12 @@ "fmt" "io" + "git.sr.ht/~rjarry/aerc/config" "git.sr.ht/~rjarry/aerc/lib" "git.sr.ht/~rjarry/aerc/lib/calendar" "git.sr.ht/~rjarry/aerc/lib/format" + "git.sr.ht/~rjarry/aerc/lib/ui" + "git.sr.ht/~rjarry/aerc/log" "git.sr.ht/~rjarry/aerc/models" "git.sr.ht/~rjarry/aerc/widgets" "github.com/emersion/go-message/mail" @@ -28,7 +31,6 @@ } func (invite) Execute(aerc *widgets.Aerc, args []string) error { - acct := aerc.SelectedAccount() if acct == nil { return errors.New("no account selected") @@ -47,7 +49,7 @@ return fmt.Errorf("no invitation found (missing text/calendar)") } - subject := trimLocalizedRe(msg.Envelope.Subject) + subject := trimLocalizedRe(msg.Envelope.Subject, acct.AccountConfig().LocalizedRe) switch args[0] { case "accept": subject = "Accepted: " + subject @@ -90,9 +92,7 @@ } } - var ( - to []*mail.Address - ) + var to []*mail.Address if len(msg.Envelope.ReplyTo) != 0 { to = msg.Envelope.ReplyTo @@ -100,7 +100,7 @@ to = msg.Envelope.From } - if !aerc.Config().Compose.ReplyToSelf { + if !config.Compose.ReplyToSelf { for i, v := range to { if v.Address == from.Address { to = append(to[:i], to[i+1:]...) @@ -148,7 +148,7 @@ } addTab := func(cr *calendar.Reply) error { - composer, err := widgets.NewComposer(aerc, acct, aerc.Config(), + composer, err := widgets.NewComposer(aerc, acct, acct.AccountConfig(), acct.Worker(), "", h, original) if err != nil { aerc.PushError("Error: " + err.Error()) @@ -156,7 +156,10 @@ } composer.SetContents(cr.PlainText) - composer.AppendPart(cr.MimeType, cr.Params, cr.CalendarText) + err = composer.AppendPart(cr.MimeType, cr.Params, cr.CalendarText) + if err != nil { + return fmt.Errorf("failed to write invitation: %w", err) + } composer.FocusTerminal() tab := aerc.NewTab(composer, subject) @@ -166,7 +169,7 @@ } else { tab.Name = subject } - tab.Content.Invalidate() + ui.Invalidate() }) composer.OnClose(func(c *widgets.Composer) { @@ -183,7 +186,10 @@ aerc.PushError(err.Error()) return } else { - addTab(cr) + err := addTab(cr) + if err != nil { + log.Warnf("failed to add tab: %v", err) + } } }) return nil diff -Nru aerc-0.11.0/commands/msg/mark.go aerc-0.14.0/commands/msg/mark.go --- aerc-0.11.0/commands/msg/mark.go 2022-07-11 19:25:05.000000000 +0000 +++ aerc-0.14.0/commands/msg/mark.go 2023-01-04 15:38:38.000000000 +0000 @@ -23,32 +23,59 @@ func (Mark) Execute(aerc *widgets.Aerc, args []string) error { h := newHelper(aerc) - selected, err := h.msgProvider.SelectedMessage() - if err != nil { - return err + OnSelectedMessage := func(fn func(uint32)) error { + if fn == nil { + return fmt.Errorf("no operation selected") + } + selected, err := h.msgProvider.SelectedMessage() + if err != nil { + return err + } + fn(selected.Uid) + return nil } store, err := h.store() if err != nil { return err } - opts, _, err := getopt.Getopts(args, "atv") + marker := store.Marker() + opts, _, err := getopt.Getopts(args, "atvVT") if err != nil { return err } var all bool var toggle bool var visual bool + var clearVisual bool + var thread bool for _, opt := range opts { switch opt.Option { case 'a': all = true case 'v': visual = true + clearVisual = true + case 'V': + visual = true case 't': toggle = true + case 'T': + thread = true } } + if thread && len(store.Threads()) == 0 { + return fmt.Errorf("No threads found") + } + + if thread && all { + return fmt.Errorf("-a and -T are mutually exclusive") + } + + if thread && visual { + return fmt.Errorf("-v and -T are mutually exclusive") + } + switch args[0] { case "mark": if all && visual { @@ -57,21 +84,28 @@ var modFunc func(uint32) if toggle { - modFunc = store.ToggleMark + modFunc = marker.ToggleMark } else { - modFunc = store.Mark + modFunc = marker.Mark } - if all { + switch { + case all: uids := store.Uids() for _, uid := range uids { modFunc(uid) } return nil - } else if visual { - store.ToggleVisualMark() + case visual: + marker.ToggleVisualMark(clearVisual) return nil - } else { - modFunc(selected.Uid) + default: + if thread { + for _, uid := range store.SelectedThread().Root().Uids() { + modFunc(uid) + } + } else { + return OnSelectedMessage(modFunc) + } return nil } @@ -80,24 +114,31 @@ return fmt.Errorf("visual mode not supported for this command") } - if all && toggle { + switch { + case all && toggle: uids := store.Uids() for _, uid := range uids { - store.ToggleMark(uid) + marker.ToggleMark(uid) } return nil - } else if all && !toggle { - store.ClearVisualMark() + case all && !toggle: + marker.ClearVisualMark() return nil - } else { - store.Unmark(selected.Uid) + default: + if thread { + for _, uid := range store.SelectedThread().Root().Uids() { + marker.Unmark(uid) + } + } else { + return OnSelectedMessage(marker.Unmark) + } return nil } case "remark": - if all || visual || toggle { + if all || visual || toggle || thread { return fmt.Errorf("Usage: :remark") } - store.Remark() + marker.Remark() return nil } return nil // never reached diff -Nru aerc-0.11.0/commands/msg/modify-labels.go aerc-0.14.0/commands/msg/modify-labels.go --- aerc-0.11.0/commands/msg/modify-labels.go 2022-07-11 19:25:05.000000000 +0000 +++ aerc-0.14.0/commands/msg/modify-labels.go 2023-01-04 15:38:38.000000000 +0000 @@ -16,7 +16,7 @@ } func (ModifyLabels) Aliases() []string { - return []string{"modify-labels"} + return []string{"modify-labels", "tag"} } func (ModifyLabels) Complete(aerc *widgets.Aerc, args []string) []string { @@ -52,12 +52,12 @@ } } store.ModifyLabels(uids, add, remove, func( - msg types.WorkerMessage) { - + msg types.WorkerMessage, + ) { switch msg := msg.(type) { case *types.Done: aerc.PushStatus("labels updated", 10*time.Second) - store.ClearVisualMark() + store.Marker().ClearVisualMark() case *types.Error: aerc.PushError(msg.Error.Error()) } diff -Nru aerc-0.11.0/commands/msg/move.go aerc-0.14.0/commands/msg/move.go --- aerc-0.11.0/commands/msg/move.go 2022-07-11 19:25:05.000000000 +0000 +++ aerc-0.14.0/commands/msg/move.go 2023-01-04 15:38:38.000000000 +0000 @@ -5,11 +5,14 @@ "strings" "time" - "git.sr.ht/~sircmpwn/getopt" - "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/widgets" "git.sr.ht/~rjarry/aerc/worker/types" + "git.sr.ht/~sircmpwn/getopt" ) type Move struct{} @@ -34,45 +37,85 @@ if err != nil { return err } - var ( - createParents bool - ) + var createParents bool for _, opt := range opts { - switch opt.Option { - case 'p': + if opt.Option == 'p' { createParents = true } } h := newHelper(aerc) - store, err := h.store() + acct, err := h.account() if err != nil { return err } - uids, err := h.markedOrSelectedUids() + store, err := h.store() if err != nil { return err } - acct, err := h.account() + msgs, err := h.messages() if err != nil { return err } - _, isMsgView := h.msgProvider.(*widgets.MessageViewer) - if isMsgView { - aerc.RemoveTab(h.msgProvider) - } - store.Next() - acct.Messages().Invalidate() + var uids []uint32 + for _, msg := range msgs { + uids = append(uids, msg.Uid) + } + marker := store.Marker() + marker.ClearVisualMark() + next := findNextNonDeleted(uids, store) joinedArgs := strings.Join(args[optind:], " ") - store.Move(uids, joinedArgs, createParents, func( - msg types.WorkerMessage) { + store.Move(uids, joinedArgs, createParents, func( + msg types.WorkerMessage, + ) { switch msg := msg.(type) { case *types.Done: - aerc.PushStatus("Message moved to "+joinedArgs, 10*time.Second) + handleDone(aerc, acct, next, "Messages moved to "+joinedArgs, store) case *types.Error: aerc.PushError(msg.Error.Error()) + marker.Remark() } }) + return nil } + +func handleDone( + aerc *widgets.Aerc, + acct *widgets.AccountView, + next *models.MessageInfo, + message string, + store *lib.MessageStore, +) { + h := newHelper(aerc) + aerc.PushStatus(message, 10*time.Second) + mv, isMsgView := h.msgProvider.(*widgets.MessageViewer) + switch { + case isMsgView && !config.Ui.NextMessageOnDelete: + aerc.RemoveTab(h.msgProvider) + case isMsgView: + if next == nil { + aerc.RemoveTab(h.msgProvider) + acct.Messages().Select(-1) + ui.Invalidate() + return + } + lib.NewMessageStoreView(next, mv.MessageView().SeenFlagSet(), + store, aerc.Crypto, aerc.DecryptKeys, + func(view lib.MessageView, err error) { + if err != nil { + aerc.PushError(err.Error()) + return + } + nextMv := widgets.NewMessageViewer(acct, view) + aerc.ReplaceTab(mv, nextMv, next.Envelope.Subject) + }) + default: + if next == nil { + // We moved the last message, select the new last message + // instead of the first message + acct.Messages().Select(-1) + } + } +} diff -Nru aerc-0.11.0/commands/msg/msg.go aerc-0.14.0/commands/msg/msg.go --- aerc-0.11.0/commands/msg/msg.go 2022-07-11 19:25:05.000000000 +0000 +++ aerc-0.14.0/commands/msg/msg.go 2023-01-04 15:38:38.000000000 +0000 @@ -4,9 +4,7 @@ "git.sr.ht/~rjarry/aerc/commands" ) -var ( - MessageCommands *commands.Commands -) +var MessageCommands *commands.Commands func register(cmd commands.Command) { if MessageCommands == nil { diff -Nru aerc-0.11.0/commands/msg/pipe.go aerc-0.14.0/commands/msg/pipe.go --- aerc-0.11.0/commands/msg/pipe.go 2022-07-11 19:25:05.000000000 +0000 +++ aerc-0.14.0/commands/msg/pipe.go 2023-01-04 15:38:38.000000000 +0000 @@ -5,12 +5,14 @@ "fmt" "io" "os/exec" + "regexp" "sort" "time" "git.sr.ht/~rjarry/aerc/commands" - "git.sr.ht/~rjarry/aerc/logging" + "git.sr.ht/~rjarry/aerc/log" "git.sr.ht/~rjarry/aerc/widgets" + mboxer "git.sr.ht/~rjarry/aerc/worker/mbox" "git.sr.ht/~rjarry/aerc/worker/types" "git.sr.ht/~sircmpwn/getopt" @@ -62,7 +64,7 @@ return errors.New("Usage: pipe [-mp] [args...]") } - provider := aerc.SelectedTab().(widgets.ProvidesMessage) + provider := aerc.SelectedTabContent().(widgets.ProvidesMessage) if !pipeFull && !pipePart { if _, ok := provider.(*widgets.MessageViewer); ok { pipePart = true @@ -90,10 +92,13 @@ return } go func() { - defer logging.PanicHandler() + defer log.PanicHandler() defer pipe.Close() - io.Copy(pipe, reader) + _, err := io.Copy(pipe, reader) + if err != nil { + log.Errorf("failed to send data to pipe: %v", err) + } }() err = ecmd.Run() if err != nil { @@ -118,6 +123,18 @@ h := newHelper(aerc) store, err := h.store() if err != nil { + if mv, ok := provider.(*widgets.MessageViewer); ok { + mv.MessageView().FetchFull(func(reader io.Reader) { + if background { + doExec(reader) + } else { + doTerm(reader, + fmt.Sprintf("%s <%s", + cmd[0], title)) + } + }) + return nil + } return err } uids, err = h.markedOrSelectedUids() @@ -149,7 +166,7 @@ }) go func() { - defer logging.PanicHandler() + defer log.PanicHandler() select { case <-done: @@ -163,18 +180,28 @@ } } - // Sort all messages by increasing Message-Id header. - // This will ensure that patch series are applied in order. - sort.Slice(messages, func(i, j int) bool { - infoi := store.Messages[messages[i].Content.Uid] - infoj := store.Messages[messages[j].Content.Uid] - if infoi == nil || infoj == nil { - return false + is_git_patches := false + for _, msg := range messages { + info := store.Messages[msg.Content.Uid] + if info != nil && patchSeriesRe.MatchString(info.Envelope.Subject) { + is_git_patches = true + break } - return infoi.Envelope.MessageId < infoj.Envelope.MessageId - }) + } + if is_git_patches { + // Sort all messages by increasing Message-Id header. + // This will ensure that patch series are applied in order. + sort.Slice(messages, func(i, j int) bool { + infoi := store.Messages[messages[i].Content.Uid] + infoj := store.Messages[messages[j].Content.Uid] + if infoi == nil || infoj == nil { + return false + } + return infoi.Envelope.Subject < infoj.Envelope.Subject + }) + } - reader := newMessagesReader(messages) + reader := newMessagesReader(messages, len(messages) > 1) if background { doExec(reader) } else { @@ -200,47 +227,31 @@ } }) } - provider.Store().ClearVisualMark() + if store := provider.Store(); store != nil { + store.Marker().ClearVisualMark() + } return nil } -// The actual sender address does not matter, nor does the date. This is mostly indended -// for git am which requires separators to look like something valid. -// https://github.com/git/git/blame/v2.35.1/builtin/mailsplit.c#L15-L44 -var mboxSeparator []byte = []byte("From ???@??? Tue Jun 23 16:32:49 1981\n") - -type messagesReader struct { - messages []*types.FullMessage - mbox bool - separatorNeeded bool -} - -func newMessagesReader(messages []*types.FullMessage) io.Reader { - needMboxSeparator := len(messages) > 1 - return &messagesReader{messages, needMboxSeparator, needMboxSeparator} -} - -func (mr *messagesReader) Read(p []byte) (n int, err error) { - for len(mr.messages) > 0 { - if mr.separatorNeeded { - offset := copy(p, mboxSeparator) - n, err = mr.messages[0].Content.Reader.Read(p[offset:]) - n += offset - mr.separatorNeeded = false - } else { - n, err = mr.messages[0].Content.Reader.Read(p) - } - if err == io.EOF { - mr.messages = mr.messages[1:] - mr.separatorNeeded = mr.mbox - } - if n > 0 || err != io.EOF { - if err == io.EOF && len(mr.messages) > 0 { - // Don't return EOF yet. More messages remain. - err = nil +func newMessagesReader(messages []*types.FullMessage, useMbox bool) io.Reader { + pr, pw := io.Pipe() + go func() { + defer pw.Close() + for _, msg := range messages { + var err error + if useMbox { + err = mboxer.Write(pw, msg.Content.Reader, "", time.Now()) + } else { + _, err = io.Copy(pw, msg.Content.Reader) + } + if err != nil { + log.Warnf("failed to write data: %v", err) } - return n, err } - } - return 0, io.EOF + }() + return pr } + +var patchSeriesRe = regexp.MustCompile( + `^.*\[(RFC )?PATCH( [^\]]+)? \d+/\d+] .+$`, +) diff -Nru aerc-0.11.0/commands/msg/read.go aerc-0.14.0/commands/msg/read.go --- aerc-0.11.0/commands/msg/read.go 2022-07-11 19:25:05.000000000 +0000 +++ aerc-0.14.0/commands/msg/read.go 2023-01-04 15:38:38.000000000 +0000 @@ -2,13 +2,10 @@ import ( "fmt" - "sync" "time" "git.sr.ht/~sircmpwn/getopt" - "git.sr.ht/~rjarry/aerc/lib" - "git.sr.ht/~rjarry/aerc/logging" "git.sr.ht/~rjarry/aerc/models" "git.sr.ht/~rjarry/aerc/widgets" "git.sr.ht/~rjarry/aerc/worker/types" @@ -36,7 +33,6 @@ // If this was called as 'read' or 'unread', it has the same effect as // 'flag' or 'unflag', respectively, but the 'Seen' flag is affected. func (FlagMsg) Execute(aerc *widgets.Aerc, args []string) error { - // The flag to change var flag models.Flag // User-readable name of the flag to change @@ -103,11 +99,12 @@ flagChosen = true } } - if toggle { + switch { + case toggle: actionName = "Toggling" - } else if enable { + case enable: actionName = "Setting" - } else { + default: actionName = "Unsetting" } if optind != len(args) { @@ -158,42 +155,27 @@ } } - var wg sync.WaitGroup - success := true - if len(toEnable) != 0 { - submitFlagChange(aerc, store, toEnable, flag, true, &wg, &success) + store.Flag(toEnable, flag, true, func(msg types.WorkerMessage) { + switch msg := msg.(type) { + case *types.Done: + aerc.PushStatus(actionName+" flag '"+flagName+"' successful", 10*time.Second) + store.Marker().ClearVisualMark() + case *types.Error: + aerc.PushError(msg.Error.Error()) + } + }) } if len(toDisable) != 0 { - submitFlagChange(aerc, store, toDisable, flag, false, &wg, &success) + store.Flag(toDisable, flag, false, func(msg types.WorkerMessage) { + switch msg := msg.(type) { + case *types.Done: + aerc.PushStatus(actionName+" flag '"+flagName+"' successful", 10*time.Second) + store.Marker().ClearVisualMark() + case *types.Error: + aerc.PushError(msg.Error.Error()) + } + }) } - - // We need to do flagging in the background, else we block the main thread - go func() { - defer logging.PanicHandler() - - wg.Wait() - if success { - aerc.PushStatus(actionName+" flag '"+flagName+"' successful", 10*time.Second) - store.ClearVisualMark() - } - }() - return nil } - -func submitFlagChange(aerc *widgets.Aerc, store *lib.MessageStore, - uids []uint32, flag models.Flag, newState bool, - wg *sync.WaitGroup, success *bool) { - store.Flag(uids, flag, newState, func(msg types.WorkerMessage) { - wg.Add(1) - switch msg := msg.(type) { - case *types.Done: - wg.Done() - case *types.Error: - aerc.PushError(msg.Error.Error()) - *success = false - wg.Done() - } - }) -} diff -Nru aerc-0.11.0/commands/msg/recall.go aerc-0.14.0/commands/msg/recall.go --- aerc-0.11.0/commands/msg/recall.go 2022-07-11 19:25:05.000000000 +0000 +++ aerc-0.14.0/commands/msg/recall.go 2023-01-04 15:38:38.000000000 +0000 @@ -13,6 +13,8 @@ "github.com/pkg/errors" "git.sr.ht/~rjarry/aerc/lib" + "git.sr.ht/~rjarry/aerc/lib/ui" + "git.sr.ht/~rjarry/aerc/log" "git.sr.ht/~rjarry/aerc/models" "git.sr.ht/~rjarry/aerc/widgets" "git.sr.ht/~rjarry/aerc/worker/types" @@ -41,8 +43,7 @@ return err } for _, opt := range opts { - switch opt.Option { - case 'f': + if opt.Option == 'f' { force = true } } @@ -51,7 +52,7 @@ return errors.New("Usage: recall [-f]") } - widget := aerc.SelectedTab().(widgets.ProvidesMessage) + widget := aerc.SelectedTabContent().(widgets.ProvidesMessage) acct := widget.SelectedAccount() if acct == nil { return errors.New("No account selected") @@ -69,9 +70,9 @@ if err != nil { return errors.Wrap(err, "Recall failed") } - acct.Logger().Println("Recalling message " + msgInfo.Envelope.MessageId) + log.Debugf("Recalling message <%s>", msgInfo.Envelope.MessageId) - composer, err := widgets.NewComposer(aerc, acct, aerc.Config(), + composer, err := widgets.NewComposer(aerc, acct, acct.AccountConfig(), acct.Worker(), "", msgInfo.RFC822Headers, models.OriginalMail{}) if err != nil { @@ -93,7 +94,7 @@ } else { tab.Name = subject } - tab.Content.Invalidate() + ui.Invalidate() }) composer.OnClose(func(composer *widgets.Composer) { worker := composer.Worker() @@ -130,16 +131,15 @@ deleteMessage() default: } - return }, ) aerc.AddDialog(confirm) } - }) } - lib.NewMessageStoreView(msgInfo, store, aerc.Crypto, aerc.DecryptKeys, + lib.NewMessageStoreView(msgInfo, acct.UiConfig().AutoMarkRead, + store, aerc.Crypto, aerc.DecryptKeys, func(msg lib.MessageView, err error) { if err != nil { aerc.PushError(err.Error()) @@ -183,7 +183,10 @@ composer.SetEncrypt(md.IsEncrypted) } if md.IsSigned { - composer.SetSign(md.IsSigned) + err = composer.SetSign(md.IsSigned) + if err != nil { + log.Warnf("failed to set signed state: %v", err) + } } } addTab() @@ -197,23 +200,26 @@ } bs, err := msg.BodyStructure().PartAtIndex(p) if err != nil { - acct.Logger().Println("recall: PartAtIndex:", err) + log.Warnf("cannot get PartAtIndex %v: %v", p, err) continue } msg.FetchBodyPart(p, func(reader io.Reader) { - mime := fmt.Sprintf("%s/%s", bs.MIMEType, bs.MIMESubType) - name, ok := bs.Params["name"] + mime := bs.FullMIMEType() + params := lib.SetUtf8Charset(bs.Params) + name, ok := params["name"] if !ok { name = fmt.Sprintf("%s_%s_%d", bs.MIMEType, bs.MIMESubType, rand.Uint64()) } mu.Lock() - composer.AddPartAttachment(name, mime, bs.Params, reader) + err := composer.AddPartAttachment(name, mime, params, reader) mu.Unlock() + if err != nil { + log.Errorf(err.Error()) + aerc.PushError(err.Error()) + } }) } - }) - }) return nil diff -Nru aerc-0.11.0/commands/msg/reply.go aerc-0.14.0/commands/msg/reply.go --- aerc-0.11.0/commands/msg/reply.go 2022-07-11 19:25:05.000000000 +0000 +++ aerc-0.14.0/commands/msg/reply.go 2023-01-04 15:38:38.000000000 +0000 @@ -10,8 +10,13 @@ "git.sr.ht/~sircmpwn/getopt" + "git.sr.ht/~rjarry/aerc/commands/account" + "git.sr.ht/~rjarry/aerc/config" "git.sr.ht/~rjarry/aerc/lib" + "git.sr.ht/~rjarry/aerc/lib/crypto" "git.sr.ht/~rjarry/aerc/lib/format" + "git.sr.ht/~rjarry/aerc/lib/ui" + "git.sr.ht/~rjarry/aerc/log" "git.sr.ht/~rjarry/aerc/models" "git.sr.ht/~rjarry/aerc/widgets" "github.com/emersion/go-message/mail" @@ -32,22 +37,25 @@ } func (reply) Execute(aerc *widgets.Aerc, args []string) error { - opts, optind, err := getopt.Getopts(args, "aqT:") + opts, optind, err := getopt.Getopts(args, "acqT:") if err != nil { return err } if optind != len(args) { - return errors.New("Usage: reply [-aq -T