init: pristine aerc 0.20.0 source
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
)
|
||||
|
||||
type Abort struct{}
|
||||
|
||||
func init() {
|
||||
commands.Register(Abort{})
|
||||
}
|
||||
|
||||
func (Abort) Description() string {
|
||||
return "Close the composer without sending."
|
||||
}
|
||||
|
||||
func (Abort) Context() commands.CommandContext {
|
||||
return commands.COMPOSE_EDIT | commands.COMPOSE_REVIEW
|
||||
}
|
||||
|
||||
func (Abort) Aliases() []string {
|
||||
return []string{"abort"}
|
||||
}
|
||||
|
||||
func (Abort) Execute(args []string) error {
|
||||
composer, _ := app.SelectedTabContent().(*app.Composer)
|
||||
app.RemoveTab(composer, true)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
)
|
||||
|
||||
type AttachKey struct{}
|
||||
|
||||
func init() {
|
||||
commands.Register(AttachKey{})
|
||||
}
|
||||
|
||||
func (AttachKey) Description() string {
|
||||
return "Attach the public key of the current account."
|
||||
}
|
||||
|
||||
func (AttachKey) Context() commands.CommandContext {
|
||||
return commands.COMPOSE_EDIT | commands.COMPOSE_REVIEW
|
||||
}
|
||||
|
||||
func (AttachKey) Aliases() []string {
|
||||
return []string{"attach-key"}
|
||||
}
|
||||
|
||||
func (AttachKey) Execute(args []string) error {
|
||||
composer, _ := app.SelectedTabContent().(*app.Composer)
|
||||
return composer.SetAttachKey(!composer.AttachKey())
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/config"
|
||||
"git.sr.ht/~rjarry/aerc/lib"
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/lib/ui"
|
||||
"git.sr.ht/~rjarry/aerc/lib/xdg"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type Attach struct {
|
||||
Menu bool `opt:"-m" desc:"Select files from file-picker-cmd."`
|
||||
Name string `opt:"-r" desc:"<name> <cmd...>: Generate attachment from command output."`
|
||||
Path string `opt:"path" required:"false" complete:"CompletePath" desc:"Attachment file path."`
|
||||
Args string `opt:"..." required:"false"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(Attach{})
|
||||
}
|
||||
|
||||
func (Attach) Description() string {
|
||||
return "Attach the file at the given path to the email."
|
||||
}
|
||||
|
||||
func (Attach) Context() commands.CommandContext {
|
||||
return commands.COMPOSE_EDIT | commands.COMPOSE_REVIEW
|
||||
}
|
||||
|
||||
func (Attach) Aliases() []string {
|
||||
return []string{"attach"}
|
||||
}
|
||||
|
||||
func (*Attach) CompletePath(arg string) []string {
|
||||
return commands.CompletePath(arg, false)
|
||||
}
|
||||
|
||||
func (a Attach) Execute(args []string) error {
|
||||
if a.Menu && a.Name != "" {
|
||||
return errors.New("-m and -r are mutually exclusive")
|
||||
}
|
||||
switch {
|
||||
case a.Menu:
|
||||
return a.openMenu()
|
||||
case a.Name != "":
|
||||
if a.Path == "" {
|
||||
return errors.New("command is required")
|
||||
}
|
||||
return a.readCommand()
|
||||
default:
|
||||
if a.Args != "" {
|
||||
return errors.New("only a single path is supported")
|
||||
}
|
||||
return a.addPath(a.Path)
|
||||
}
|
||||
}
|
||||
|
||||
func (a Attach) addPath(path string) error {
|
||||
path = xdg.ExpandHome(path)
|
||||
attachments, err := filepath.Glob(path)
|
||||
if err != nil && errors.Is(err, filepath.ErrBadPattern) {
|
||||
log.Warnf("failed to parse as globbing pattern: %v", err)
|
||||
attachments = []string{path}
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(path, ".") && !strings.Contains(path, "/.") {
|
||||
log.Debugf("removing hidden files from glob results")
|
||||
for i := len(attachments) - 1; i >= 0; i-- {
|
||||
if strings.HasPrefix(filepath.Base(attachments[i]), ".") {
|
||||
if i == len(attachments)-1 {
|
||||
attachments = attachments[:i]
|
||||
continue
|
||||
}
|
||||
attachments = append(attachments[:i], attachments[i+1:]...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
composer, _ := app.SelectedTabContent().(*app.Composer)
|
||||
for _, attach := range attachments {
|
||||
log.Debugf("attaching '%s'", attach)
|
||||
|
||||
pathinfo, err := os.Stat(attach)
|
||||
if err != nil {
|
||||
log.Errorf("failed to stat file: %v", err)
|
||||
app.PushError(err.Error())
|
||||
return err
|
||||
} else if pathinfo.IsDir() && len(attachments) == 1 {
|
||||
app.PushError("Attachment must be a file, not a directory")
|
||||
return nil
|
||||
}
|
||||
|
||||
composer.AddAttachment(attach)
|
||||
}
|
||||
|
||||
if len(attachments) == 1 {
|
||||
app.PushSuccess(fmt.Sprintf("Attached %s", path))
|
||||
} else {
|
||||
app.PushSuccess(fmt.Sprintf("Attached %d files", len(attachments)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a Attach) openMenu() error {
|
||||
filePickerCmd := config.Compose.FilePickerCmd
|
||||
if filePickerCmd == "" {
|
||||
return fmt.Errorf("no file-picker-cmd defined")
|
||||
}
|
||||
|
||||
if strings.Contains(filePickerCmd, "%s") {
|
||||
filePickerCmd = strings.ReplaceAll(filePickerCmd, "%s", a.Path)
|
||||
}
|
||||
|
||||
picks, err := os.CreateTemp("", "aerc-filepicker-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var filepicker *exec.Cmd
|
||||
if strings.Contains(filePickerCmd, "%f") {
|
||||
filePickerCmd = strings.ReplaceAll(filePickerCmd, "%f", picks.Name())
|
||||
filepicker = exec.Command("sh", "-c", filePickerCmd)
|
||||
} else {
|
||||
filepicker = exec.Command("sh", "-c", filePickerCmd+" >&3")
|
||||
filepicker.ExtraFiles = append(filepicker.ExtraFiles, picks)
|
||||
}
|
||||
|
||||
t, err := app.NewTerminal(filepicker)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t.Focus(true)
|
||||
t.OnClose = func(err error) {
|
||||
defer func() {
|
||||
if err := picks.Close(); err != nil {
|
||||
log.Errorf("error closing file: %v", err)
|
||||
}
|
||||
if err := os.Remove(picks.Name()); err != nil {
|
||||
log.Errorf("could not remove tmp file: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
app.CloseDialog()
|
||||
|
||||
if err != nil {
|
||||
log.Errorf("terminal closed with error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = picks.Seek(0, io.SeekStart)
|
||||
if err != nil {
|
||||
log.Errorf("seek failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(picks)
|
||||
for scanner.Scan() {
|
||||
f := strings.TrimSpace(scanner.Text())
|
||||
if _, err := os.Stat(f); err != nil {
|
||||
continue
|
||||
}
|
||||
log.Tracef("File picker attaches: %v", f)
|
||||
err := a.addPath(f)
|
||||
if err != nil {
|
||||
log.Errorf("attach failed for file %s: %v", f, err)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
app.AddDialog(app.DefaultDialog(
|
||||
ui.NewBox(t, "File Picker", "", app.SelectedAccountUiConfig()),
|
||||
))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a Attach) readCommand() error {
|
||||
cmd := exec.Command("sh", "-c", a.Path+" "+a.Args)
|
||||
|
||||
data, err := cmd.Output()
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Output")
|
||||
}
|
||||
|
||||
reader := bufio.NewReader(bytes.NewReader(data))
|
||||
|
||||
mimeType, mimeParams, err := lib.FindMimeType(a.Name, reader)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "FindMimeType")
|
||||
}
|
||||
|
||||
mimeParams["name"] = a.Name
|
||||
|
||||
composer, _ := app.SelectedTabContent().(*app.Composer)
|
||||
err = composer.AddPartAttachment(a.Name, mimeType, mimeParams, reader)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "AddPartAttachment")
|
||||
}
|
||||
|
||||
app.PushSuccess(fmt.Sprintf("Attached %s", a.Name))
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
)
|
||||
|
||||
type CC struct {
|
||||
Recipients string `opt:"recipients" complete:"CompleteAddress" desc:"Recipient from address book."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(CC{})
|
||||
}
|
||||
|
||||
func (CC) Description() string {
|
||||
return "Add the given address(es) to the Cc or Bcc header."
|
||||
}
|
||||
|
||||
func (CC) Context() commands.CommandContext {
|
||||
return commands.COMPOSE_EDIT | commands.COMPOSE_REVIEW
|
||||
}
|
||||
|
||||
func (CC) Aliases() []string {
|
||||
return []string{"cc", "bcc"}
|
||||
}
|
||||
|
||||
func (*CC) CompleteAddress(arg string) []string {
|
||||
return commands.GetAddress(arg)
|
||||
}
|
||||
|
||||
func (c CC) Execute(args []string) error {
|
||||
composer, _ := app.SelectedTabContent().(*app.Composer)
|
||||
|
||||
switch args[0] {
|
||||
case "cc":
|
||||
return composer.AddEditor("Cc", c.Recipients, true)
|
||||
case "bcc":
|
||||
return composer.AddEditor("Bcc", c.Recipients, true)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type Detach struct {
|
||||
Path string `opt:"path" required:"false" complete:"CompletePath" desc:"Attachment file path."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(Detach{})
|
||||
}
|
||||
|
||||
func (Detach) Description() string {
|
||||
return "Detach the file with the given path from the composed email."
|
||||
}
|
||||
|
||||
func (Detach) Context() commands.CommandContext {
|
||||
return commands.COMPOSE_EDIT | commands.COMPOSE_REVIEW
|
||||
}
|
||||
|
||||
func (Detach) Aliases() []string {
|
||||
return []string{"detach"}
|
||||
}
|
||||
|
||||
func (*Detach) CompletePath(arg string) []string {
|
||||
composer, _ := app.SelectedTabContent().(*app.Composer)
|
||||
return commands.FilterList(composer.GetAttachments(), arg, nil)
|
||||
}
|
||||
|
||||
func (d Detach) Execute(args []string) error {
|
||||
composer, _ := app.SelectedTabContent().(*app.Composer)
|
||||
|
||||
if d.Path == "" {
|
||||
// if no attachment is specified, delete the first in the list
|
||||
atts := composer.GetAttachments()
|
||||
if len(atts) > 0 {
|
||||
d.Path = atts[0]
|
||||
} else {
|
||||
return fmt.Errorf("No attachments to delete")
|
||||
}
|
||||
}
|
||||
|
||||
return d.removePath(d.Path)
|
||||
}
|
||||
|
||||
func (d Detach) removePath(path string) error {
|
||||
composer, _ := app.SelectedTabContent().(*app.Composer)
|
||||
|
||||
// If we don't get an error here, the path was not a pattern.
|
||||
if err := composer.DeleteAttachment(path); err == nil {
|
||||
log.Debugf("detaching '%s'", path)
|
||||
app.PushSuccess(fmt.Sprintf("Detached %s", path))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
currentAttachments := composer.GetAttachments()
|
||||
detached := make([]string, 0, len(currentAttachments))
|
||||
for _, a := range currentAttachments {
|
||||
// Don't use filepath.Glob like :attach does. Not all files
|
||||
// that match the glob are already attached to the message.
|
||||
matches, err := filepath.Match(path, a)
|
||||
if err != nil && errors.Is(err, filepath.ErrBadPattern) {
|
||||
log.Warnf("failed to parse as globbing pattern: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if matches {
|
||||
log.Debugf("detaching '%s'", a)
|
||||
if err := composer.DeleteAttachment(a); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
detached = append(detached, a)
|
||||
}
|
||||
}
|
||||
|
||||
if len(detached) == 1 {
|
||||
app.PushSuccess(fmt.Sprintf("Detached %s", detached[0]))
|
||||
} else {
|
||||
app.PushSuccess(fmt.Sprintf("Detached %d files", len(detached)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/config"
|
||||
)
|
||||
|
||||
type Edit struct {
|
||||
Edit bool `opt:"-e" desc:"Force [compose].edit-headers = true."`
|
||||
NoEdit bool `opt:"-E" desc:"Force [compose].edit-headers = false."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(Edit{})
|
||||
}
|
||||
|
||||
func (Edit) Description() string {
|
||||
return "(Re-)open text editor to edit the message in progress."
|
||||
}
|
||||
|
||||
func (Edit) Context() commands.CommandContext {
|
||||
return commands.COMPOSE_REVIEW
|
||||
}
|
||||
|
||||
func (Edit) Aliases() []string {
|
||||
return []string{"edit"}
|
||||
}
|
||||
|
||||
func (e Edit) Execute(args []string) error {
|
||||
composer, ok := app.SelectedTabContent().(*app.Composer)
|
||||
if !ok {
|
||||
return errors.New("only valid while composing")
|
||||
}
|
||||
|
||||
editHeaders := (config.Compose.EditHeaders || e.Edit) && !e.NoEdit
|
||||
|
||||
err := composer.ShowTerminal(editHeaders)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
composer.FocusTerminal()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
)
|
||||
|
||||
type Encrypt struct{}
|
||||
|
||||
func init() {
|
||||
commands.Register(Encrypt{})
|
||||
}
|
||||
|
||||
func (Encrypt) Description() string {
|
||||
return "Toggle encryption of the message to all recipients."
|
||||
}
|
||||
|
||||
func (Encrypt) Context() commands.CommandContext {
|
||||
return commands.COMPOSE_EDIT | commands.COMPOSE_REVIEW
|
||||
}
|
||||
|
||||
func (Encrypt) Aliases() []string {
|
||||
return []string{"encrypt"}
|
||||
}
|
||||
|
||||
func (Encrypt) Execute(args []string) error {
|
||||
composer, _ := app.SelectedTabContent().(*app.Composer)
|
||||
composer.SetEncrypt(!composer.Encrypt())
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
)
|
||||
|
||||
type Header struct {
|
||||
Force bool `opt:"-f" desc:"Overwrite any existing header."`
|
||||
Remove bool `opt:"-d" desc:"Remove the header instead of adding it."`
|
||||
Name string `opt:"name" complete:"CompleteHeaders" desc:"Header name."`
|
||||
Value string `opt:"..." required:"false"`
|
||||
}
|
||||
|
||||
var headers = []string{
|
||||
"From",
|
||||
"To",
|
||||
"Cc",
|
||||
"Bcc",
|
||||
"Subject",
|
||||
"Comments",
|
||||
"Keywords",
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(Header{})
|
||||
}
|
||||
|
||||
func (Header) Description() string {
|
||||
return "Add or remove the specified email header."
|
||||
}
|
||||
|
||||
func (Header) Context() commands.CommandContext {
|
||||
return commands.COMPOSE_EDIT | commands.COMPOSE_REVIEW
|
||||
}
|
||||
|
||||
func (Header) Aliases() []string {
|
||||
return []string{"header"}
|
||||
}
|
||||
|
||||
func (Header) Options() string {
|
||||
return "fd"
|
||||
}
|
||||
|
||||
func (*Header) CompleteHeaders(arg string) []string {
|
||||
return commands.FilterList(headers, arg, commands.QuoteSpace)
|
||||
}
|
||||
|
||||
func (h Header) Execute(args []string) error {
|
||||
composer, _ := app.SelectedTabContent().(*app.Composer)
|
||||
|
||||
name := strings.TrimRight(h.Name, ":")
|
||||
|
||||
if h.Remove {
|
||||
return composer.DelEditor(name)
|
||||
}
|
||||
|
||||
if !h.Force {
|
||||
headers, err := composer.PrepareHeader()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if headers.Get(name) != "" && h.Value != "" {
|
||||
return fmt.Errorf(
|
||||
"Header %s is already set to %q (use -f to overwrite)",
|
||||
name, headers.Get(name))
|
||||
}
|
||||
}
|
||||
|
||||
return composer.AddEditor(name, h.Value, false)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/config"
|
||||
)
|
||||
|
||||
type Multipart struct {
|
||||
Remove bool `opt:"-d" desc:"Remove the specified mime/type."`
|
||||
Mime string `opt:"mime" metavar:"<mime/type>" complete:"CompleteMime" desc:"MIME/type name."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(Multipart{})
|
||||
}
|
||||
|
||||
func (Multipart) Description() string {
|
||||
return "Convert the message to multipart with the given mime/type part."
|
||||
}
|
||||
|
||||
func (Multipart) Context() commands.CommandContext {
|
||||
return commands.COMPOSE_EDIT | commands.COMPOSE_REVIEW
|
||||
}
|
||||
|
||||
func (Multipart) Aliases() []string {
|
||||
return []string{"multipart"}
|
||||
}
|
||||
|
||||
func (*Multipart) CompleteMime(arg string) []string {
|
||||
var completions []string
|
||||
for mime := range config.Converters {
|
||||
completions = append(completions, mime)
|
||||
}
|
||||
return commands.FilterList(completions, arg, nil)
|
||||
}
|
||||
|
||||
func (m Multipart) Execute(args []string) error {
|
||||
composer, ok := app.SelectedTabContent().(*app.Composer)
|
||||
if !ok {
|
||||
return fmt.Errorf(":multipart is only available on the compose::review screen")
|
||||
}
|
||||
|
||||
if m.Remove {
|
||||
return composer.RemovePart(m.Mime)
|
||||
} else {
|
||||
_, found := config.Converters[m.Mime]
|
||||
if !found {
|
||||
return fmt.Errorf("no command defined for MIME type: %s", m.Mime)
|
||||
}
|
||||
err := composer.AppendPart(
|
||||
m.Mime,
|
||||
map[string]string{"Charset": "UTF-8"},
|
||||
// the actual content of the part will be rendered
|
||||
// every time the body of the email is updated
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
)
|
||||
|
||||
type NextPrevField struct{}
|
||||
|
||||
func init() {
|
||||
commands.Register(NextPrevField{})
|
||||
}
|
||||
|
||||
func (NextPrevField) Description() string {
|
||||
return "Cycle between header input fields."
|
||||
}
|
||||
|
||||
func (NextPrevField) Context() commands.CommandContext {
|
||||
return commands.COMPOSE_EDIT
|
||||
}
|
||||
|
||||
func (NextPrevField) Aliases() []string {
|
||||
return []string{"next-field", "prev-field"}
|
||||
}
|
||||
|
||||
func (NextPrevField) Execute(args []string) error {
|
||||
composer, _ := app.SelectedTabContent().(*app.Composer)
|
||||
var ok bool
|
||||
if args[0] == "prev-field" {
|
||||
ok = composer.PrevField()
|
||||
} else {
|
||||
ok = composer.NextField()
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Errorf("%s not available when edit-headers=true", args[0])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
)
|
||||
|
||||
type Postpone struct {
|
||||
Folder string `opt:"-t" complete:"CompleteFolder" desc:"Override the target folder."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(Postpone{})
|
||||
}
|
||||
|
||||
func (Postpone) Description() string {
|
||||
return "Save the current state of the message to the postpone folder."
|
||||
}
|
||||
|
||||
func (Postpone) Context() commands.CommandContext {
|
||||
return commands.COMPOSE_REVIEW
|
||||
}
|
||||
|
||||
func (Postpone) Aliases() []string {
|
||||
return []string{"postpone"}
|
||||
}
|
||||
|
||||
func (*Postpone) CompleteFolder(arg string) []string {
|
||||
return commands.GetFolders(arg)
|
||||
}
|
||||
|
||||
func (p Postpone) Execute(args []string) error {
|
||||
acct := app.SelectedAccount()
|
||||
if acct == nil {
|
||||
return errors.New("No account selected")
|
||||
}
|
||||
store := acct.Store()
|
||||
if store == nil {
|
||||
return errors.New("No message store selected")
|
||||
}
|
||||
tab := app.SelectedTab()
|
||||
if tab == nil {
|
||||
return errors.New("No tab selected")
|
||||
}
|
||||
composer, _ := tab.Content.(*app.Composer)
|
||||
config := composer.Config()
|
||||
tabName := tab.Name
|
||||
|
||||
targetFolder := config.Postpone
|
||||
if composer.RecalledFrom() != "" {
|
||||
targetFolder = composer.RecalledFrom()
|
||||
}
|
||||
if p.Folder != "" {
|
||||
targetFolder = p.Folder
|
||||
}
|
||||
if targetFolder == "" {
|
||||
return errors.New("No Postpone location configured")
|
||||
}
|
||||
|
||||
log.Tracef("Postponing mail")
|
||||
|
||||
header, err := composer.PrepareHeader()
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "PrepareHeader")
|
||||
}
|
||||
header.SetContentType("text/plain", map[string]string{"charset": "UTF-8"})
|
||||
header.Set("Content-Transfer-Encoding", "quoted-printable")
|
||||
worker := composer.Worker()
|
||||
dirs := acct.Directories().List()
|
||||
alreadyCreated := false
|
||||
for _, dir := range dirs {
|
||||
if dir == targetFolder {
|
||||
alreadyCreated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
errChan := make(chan string)
|
||||
|
||||
// run this as a goroutine so we can make other progress. The message
|
||||
// will be saved once the directory is created.
|
||||
go func() {
|
||||
defer log.PanicHandler()
|
||||
|
||||
errStr := <-errChan
|
||||
if errStr != "" {
|
||||
app.PushError(errStr)
|
||||
return
|
||||
}
|
||||
|
||||
handleErr := func(err error) {
|
||||
app.PushError(err.Error())
|
||||
log.Errorf("Postponing failed: %v", err)
|
||||
app.NewTab(composer, tabName)
|
||||
}
|
||||
|
||||
app.RemoveTab(composer, false)
|
||||
buf := &bytes.Buffer{}
|
||||
|
||||
err = composer.WriteMessage(header, buf)
|
||||
if err != nil {
|
||||
handleErr(errors.Wrap(err, "WriteMessage"))
|
||||
return
|
||||
}
|
||||
store.Append(
|
||||
targetFolder,
|
||||
models.SeenFlag|models.DraftFlag,
|
||||
time.Now(),
|
||||
buf,
|
||||
buf.Len(),
|
||||
func(msg types.WorkerMessage) {
|
||||
switch msg := msg.(type) {
|
||||
case *types.Done:
|
||||
app.PushStatus("Message postponed.", 10*time.Second)
|
||||
composer.SetPostponed()
|
||||
composer.Close()
|
||||
case *types.Error:
|
||||
handleErr(msg.Error)
|
||||
}
|
||||
},
|
||||
)
|
||||
}()
|
||||
|
||||
if !alreadyCreated {
|
||||
// to synchronise the creating of the directory
|
||||
worker.PostAction(&types.CreateDirectory{
|
||||
Directory: targetFolder,
|
||||
}, func(msg types.WorkerMessage) {
|
||||
switch msg := msg.(type) {
|
||||
case *types.Done:
|
||||
errChan <- ""
|
||||
case *types.Error:
|
||||
errChan <- msg.Error.Error()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
errChan <- ""
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/commands/mode"
|
||||
"git.sr.ht/~rjarry/aerc/commands/msg"
|
||||
"git.sr.ht/~rjarry/aerc/lib/hooks"
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/lib/send"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
"git.sr.ht/~rjarry/go-opt/v2"
|
||||
"github.com/emersion/go-message/mail"
|
||||
)
|
||||
|
||||
type Send struct {
|
||||
Archive string `opt:"-a" action:"ParseArchive" metavar:"flat|year|month" complete:"CompleteArchive" desc:"Archive the message being replied to."`
|
||||
CopyTo []string `opt:"-t" complete:"CompleteFolders" action:"ParseCopyTo" desc:"Override the Copy-To folders."`
|
||||
|
||||
CopyToReplied bool `opt:"-r" desc:"Save sent message to current folder."`
|
||||
NoCopyToReplied bool `opt:"-R" desc:"Do not save sent message to current folder."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(Send{})
|
||||
}
|
||||
|
||||
func (Send) Description() string {
|
||||
return "Send the message using the configured outgoing transport."
|
||||
}
|
||||
|
||||
func (Send) Context() commands.CommandContext {
|
||||
return commands.COMPOSE_REVIEW
|
||||
}
|
||||
|
||||
func (Send) Aliases() []string {
|
||||
return []string{"send"}
|
||||
}
|
||||
|
||||
func (*Send) CompleteArchive(arg string) []string {
|
||||
return commands.FilterList(msg.ARCHIVE_TYPES, arg, nil)
|
||||
}
|
||||
|
||||
func (*Send) CompleteFolders(arg string) []string {
|
||||
return commands.GetFolders(arg)
|
||||
}
|
||||
|
||||
func (s *Send) ParseArchive(arg string) error {
|
||||
for _, a := range msg.ARCHIVE_TYPES {
|
||||
if a == arg {
|
||||
s.Archive = arg
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errors.New("unsupported archive type")
|
||||
}
|
||||
|
||||
func (o *Send) ParseCopyTo(arg string) error {
|
||||
o.CopyTo = append(o.CopyTo, strings.Split(arg, ",")...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s Send) Execute(args []string) error {
|
||||
tab := app.SelectedTab()
|
||||
if tab == nil {
|
||||
return errors.New("No selected tab")
|
||||
}
|
||||
composer, _ := tab.Content.(*app.Composer)
|
||||
|
||||
err := composer.CheckForMultipartErrors()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
config := composer.Config()
|
||||
|
||||
if len(s.CopyTo) == 0 {
|
||||
s.CopyTo = config.CopyTo
|
||||
}
|
||||
copyToReplied := config.CopyToReplied || (s.CopyToReplied && !s.NoCopyToReplied)
|
||||
|
||||
outgoing, err := config.Outgoing.ConnectionString()
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "ReadCredentials(outgoing)")
|
||||
}
|
||||
if outgoing == "" {
|
||||
return errors.New(
|
||||
"No outgoing mail transport configured for this account")
|
||||
}
|
||||
|
||||
header, err := composer.PrepareHeader()
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "PrepareHeader")
|
||||
}
|
||||
rcpts, err := listRecipients(header)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "listRecipients")
|
||||
}
|
||||
if len(rcpts) == 0 {
|
||||
return errors.New("Cannot send message with no recipients")
|
||||
}
|
||||
|
||||
if config.StripBcc {
|
||||
// Do NOT leak Bcc addresses to all recipients.
|
||||
header.Del("Bcc")
|
||||
}
|
||||
|
||||
uri, err := url.Parse(outgoing)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "url.Parse(outgoing)")
|
||||
}
|
||||
|
||||
var domain string
|
||||
if domain_, ok := config.Params["smtp-domain"]; ok {
|
||||
domain = domain_
|
||||
}
|
||||
from := config.From
|
||||
if config.UseEnvelopeFrom {
|
||||
if fl, _ := header.AddressList("from"); len(fl) != 0 {
|
||||
from = fl[0]
|
||||
}
|
||||
}
|
||||
|
||||
log.Debugf("send config uri: %s", uri.Redacted())
|
||||
log.Debugf("send config from: %s", from)
|
||||
log.Debugf("send config rcpts: %s", rcpts)
|
||||
log.Debugf("send config domain: %s", domain)
|
||||
|
||||
warnSubject := composer.ShouldWarnSubject()
|
||||
warnAttachment := composer.ShouldWarnAttachment()
|
||||
if warnSubject || warnAttachment {
|
||||
var msg string
|
||||
switch {
|
||||
case warnSubject && warnAttachment:
|
||||
msg = "The subject is empty, and you may have forgotten an attachment."
|
||||
case warnSubject:
|
||||
msg = "The subject is empty."
|
||||
default:
|
||||
msg = "You may have forgotten an attachment."
|
||||
}
|
||||
|
||||
prompt := app.NewPrompt(
|
||||
msg+" Abort send? [Y/n] ",
|
||||
func(text string) {
|
||||
if text == "n" || text == "N" {
|
||||
sendHelper(composer, header, uri, domain,
|
||||
from, rcpts, tab.Name, s.CopyTo,
|
||||
s.Archive, copyToReplied)
|
||||
}
|
||||
}, func(ctx context.Context, cmd string) ([]opt.Completion, string) {
|
||||
var comps []opt.Completion
|
||||
if cmd == "" {
|
||||
comps = append(comps, opt.Completion{Value: "y"})
|
||||
comps = append(comps, opt.Completion{Value: "n"})
|
||||
}
|
||||
return comps, ""
|
||||
},
|
||||
)
|
||||
|
||||
app.PushPrompt(prompt)
|
||||
} else {
|
||||
sendHelper(composer, header, uri, domain, from, rcpts, tab.Name,
|
||||
s.CopyTo, s.Archive, copyToReplied)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func sendHelper(composer *app.Composer, header *mail.Header, uri *url.URL, domain string,
|
||||
from *mail.Address, rcpts []*mail.Address, tabName string, copyTo []string,
|
||||
archive string, copyToReplied bool,
|
||||
) {
|
||||
// we don't want to block the UI thread while we are sending
|
||||
// so we do everything in a goroutine and hide the composer from the user
|
||||
app.RemoveTab(composer, false)
|
||||
app.PushStatus("Sending...", 10*time.Second)
|
||||
|
||||
// enter no-quit mode
|
||||
mode.NoQuit()
|
||||
|
||||
var shouldCopy bool = (len(copyTo) > 0 || copyToReplied) && !strings.HasPrefix(uri.Scheme, "jmap")
|
||||
var copyBuf bytes.Buffer
|
||||
|
||||
failCh := make(chan error)
|
||||
// writer
|
||||
go func() {
|
||||
defer log.PanicHandler()
|
||||
|
||||
var folders []string
|
||||
folders = append(folders, copyTo...)
|
||||
if copyToReplied && composer.Parent() != nil {
|
||||
folders = append(folders, composer.Parent().Folder)
|
||||
}
|
||||
sender, err := send.NewSender(
|
||||
composer.Worker(), uri, domain, from, rcpts, folders)
|
||||
if err != nil {
|
||||
failCh <- errors.Wrap(err, "send:")
|
||||
return
|
||||
}
|
||||
|
||||
var writer io.Writer = sender
|
||||
|
||||
if shouldCopy {
|
||||
writer = io.MultiWriter(writer, ©Buf)
|
||||
}
|
||||
|
||||
err = composer.WriteMessage(header, writer)
|
||||
if err != nil {
|
||||
failCh <- err
|
||||
return
|
||||
}
|
||||
failCh <- sender.Close()
|
||||
}()
|
||||
|
||||
// cleanup + copy to sent
|
||||
go func() {
|
||||
defer log.PanicHandler()
|
||||
|
||||
// leave no-quit mode
|
||||
defer mode.NoQuitDone()
|
||||
|
||||
err := <-failCh
|
||||
if err != nil {
|
||||
app.PushError(strings.ReplaceAll(err.Error(), "\n", " "))
|
||||
app.NewTab(composer, tabName)
|
||||
return
|
||||
}
|
||||
if shouldCopy {
|
||||
app.PushStatus("Copying to copy-to folders", 10*time.Second)
|
||||
errch := copyToSent(copyTo, copyToReplied, copyBuf.Len(),
|
||||
©Buf, composer)
|
||||
err = <-errch
|
||||
if err != nil {
|
||||
errmsg := fmt.Sprintf(
|
||||
"message sent, but copying to %v failed: %v",
|
||||
copyTo, err.Error())
|
||||
app.PushError(errmsg)
|
||||
composer.SetSent(archive)
|
||||
composer.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
app.PushStatus("Message sent.", 10*time.Second)
|
||||
composer.SetSent(archive)
|
||||
err = hooks.RunHook(&hooks.MailSent{
|
||||
Account: composer.Account().Name(),
|
||||
Backend: composer.Account().AccountConfig().Backend,
|
||||
Header: header,
|
||||
})
|
||||
if err != nil {
|
||||
log.Errorf("failed to trigger mail-sent hook: %v", err)
|
||||
composer.Account().PushError(fmt.Errorf("[hook.mail-sent] failed: %w", err))
|
||||
}
|
||||
composer.Close()
|
||||
}()
|
||||
}
|
||||
|
||||
func listRecipients(h *mail.Header) ([]*mail.Address, error) {
|
||||
var rcpts []*mail.Address
|
||||
for _, key := range []string{"to", "cc", "bcc"} {
|
||||
list, err := h.AddressList(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rcpts = append(rcpts, list...)
|
||||
}
|
||||
return rcpts, nil
|
||||
}
|
||||
|
||||
func copyToSent(dests []string, copyToReplied bool, n int, msg *bytes.Buffer, composer *app.Composer) <-chan error {
|
||||
errCh := make(chan error, 1)
|
||||
acct := composer.Account()
|
||||
if acct == nil {
|
||||
errCh <- errors.New("No account selected")
|
||||
return errCh
|
||||
}
|
||||
store := acct.Store()
|
||||
if store == nil {
|
||||
errCh <- errors.New("No message store selected")
|
||||
return errCh
|
||||
}
|
||||
for _, dest := range dests {
|
||||
store.Append(
|
||||
dest,
|
||||
models.SeenFlag,
|
||||
time.Now(),
|
||||
bytes.NewReader(msg.Bytes()),
|
||||
n,
|
||||
func(msg types.WorkerMessage) {
|
||||
switch msg := msg.(type) {
|
||||
case *types.Done:
|
||||
errCh <- nil
|
||||
case *types.Error:
|
||||
errCh <- msg.Error
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
if copyToReplied && composer.Parent() != nil {
|
||||
store.Append(
|
||||
composer.Parent().Folder,
|
||||
models.SeenFlag,
|
||||
time.Now(),
|
||||
bytes.NewReader(msg.Bytes()),
|
||||
n,
|
||||
func(msg types.WorkerMessage) {
|
||||
switch msg := msg.(type) {
|
||||
case *types.Done:
|
||||
errCh <- nil
|
||||
case *types.Error:
|
||||
errCh <- msg.Error
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
return errCh
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
)
|
||||
|
||||
type Sign struct{}
|
||||
|
||||
func init() {
|
||||
commands.Register(Sign{})
|
||||
}
|
||||
|
||||
func (Sign) Description() string {
|
||||
return "Sign the message using the account default key."
|
||||
}
|
||||
|
||||
func (Sign) Context() commands.CommandContext {
|
||||
return commands.COMPOSE_EDIT | commands.COMPOSE_REVIEW
|
||||
}
|
||||
|
||||
func (Sign) Aliases() []string {
|
||||
return []string{"sign"}
|
||||
}
|
||||
|
||||
func (Sign) Execute(args []string) error {
|
||||
composer, _ := app.SelectedTabContent().(*app.Composer)
|
||||
|
||||
err := composer.SetSign(!composer.Sign())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var statusline string
|
||||
|
||||
if composer.Sign() {
|
||||
statusline = "Message will be signed."
|
||||
} else {
|
||||
statusline = "Message will not be signed."
|
||||
}
|
||||
|
||||
app.PushStatus(statusline, 10*time.Second)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
)
|
||||
|
||||
type AccountSwitcher interface {
|
||||
SwitchAccount(*app.AccountView) error
|
||||
}
|
||||
|
||||
type SwitchAccount struct {
|
||||
Prev bool `opt:"-p" desc:"Switch to previous account."`
|
||||
Next bool `opt:"-n" desc:"Switch to next account."`
|
||||
Account string `opt:"account" required:"false" complete:"CompleteAccount" desc:"Account name."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(SwitchAccount{})
|
||||
}
|
||||
|
||||
func (SwitchAccount) Description() string {
|
||||
return "Change composing from the specified account."
|
||||
}
|
||||
|
||||
func (SwitchAccount) Context() commands.CommandContext {
|
||||
return commands.COMPOSE_EDIT | commands.COMPOSE_REVIEW
|
||||
}
|
||||
|
||||
func (SwitchAccount) Aliases() []string {
|
||||
return []string{"switch-account"}
|
||||
}
|
||||
|
||||
func (*SwitchAccount) CompleteAccount(arg string) []string {
|
||||
return commands.FilterList(app.AccountNames(), arg, nil)
|
||||
}
|
||||
|
||||
func (s SwitchAccount) Execute(args []string) error {
|
||||
if !s.Prev && !s.Next && s.Account == "" {
|
||||
return errors.New("Usage: switch-account -n | -p | <account-name>")
|
||||
}
|
||||
|
||||
switcher, ok := app.SelectedTabContent().(AccountSwitcher)
|
||||
if !ok {
|
||||
return errors.New("this tab cannot switch accounts")
|
||||
}
|
||||
|
||||
var acct *app.AccountView
|
||||
var err error
|
||||
|
||||
switch {
|
||||
case s.Prev:
|
||||
acct, err = app.PrevAccount()
|
||||
case s.Next:
|
||||
acct, err = app.NextAccount()
|
||||
default:
|
||||
acct, err = app.Account(s.Account)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = switcher.SwitchAccount(acct); err != nil {
|
||||
return err
|
||||
}
|
||||
acct.UpdateStatus()
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user