init: pristine aerc 0.20.0 source

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