diff --git a/aerc.go b/aerc.go index ca131e6..2420b44 100644 --- a/aerc.go +++ b/aerc.go @@ -52,7 +52,7 @@ func getCommands(selected libui.Drawable) []*commands.Commands { } } -func execCommand(aerc *widgets.Aerc, ui *libui.UI, cmd string) error { +func execCommand(aerc *widgets.Aerc, ui *libui.UI, cmd []string) error { cmds := getCommands((*aerc).SelectedTab()) for i, set := range cmds { err := set.ExecuteCommand(aerc, cmd) @@ -144,11 +144,11 @@ func main() { ui *libui.UI ) - aerc = widgets.NewAerc(conf, logger, func(cmd string) error { - return execCommand(aerc, ui, cmd) - }, func(cmd string) []string { - return getCompletions(aerc, cmd) - }) + aerc = widgets.NewAerc(conf, logger, func(cmd []string) error { + return execCommand(aerc, ui, cmd) + }, func(cmd string) []string { + return getCompletions(aerc, cmd) + }) ui, err = libui.Initialize(conf, aerc) if err != nil { diff --git a/commands/commands.go b/commands/commands.go index 4038fe2..c6f149f 100644 --- a/commands/commands.go +++ b/commands/commands.go @@ -56,11 +56,7 @@ type CommandSource interface { Commands() *Commands } -func (cmds *Commands) ExecuteCommand(aerc *widgets.Aerc, cmd string) error { - args, err := shlex.Split(cmd) - if err != nil { - return err - } +func (cmds *Commands) ExecuteCommand(aerc *widgets.Aerc, args []string) error { if len(args) == 0 { return errors.New("Expected a command.") } diff --git a/config/aerc.conf.in b/config/aerc.conf.in index 4219042..5b080e9 100644 --- a/config/aerc.conf.in +++ b/config/aerc.conf.in @@ -96,3 +96,14 @@ subject,~^\[PATCH=awk -f @SHAREDIR@/filters/hldiff #text/html=@SHAREDIR@/filters/html text/*=awk -f @SHAREDIR@/filters/plaintext #image/*=catimg -w $(tput cols) - + +[triggers] +# +# Triggers specify commands to execute when certain events occur. +# +# Example: +# new-email=exec notify-send "New email from %n" "%s" + +# +# Executed when a new email arrives in the selected folder +new-email= diff --git a/config/config.go b/config/config.go index 4a049fa..f863729 100644 --- a/config/config.go +++ b/config/config.go @@ -84,6 +84,11 @@ type ViewerConfig struct { HeaderLayout [][]string `ini:"-"` } +type TriggersConfig struct { + NewEmail string `ini:"new-email"` + ExecuteCommand func(command []string) error +} + type AercConfig struct { Bindings BindingConfig Compose ComposeConfig @@ -91,6 +96,7 @@ type AercConfig struct { Accounts []AccountConfig `ini:"-"` Filters []FilterConfig `ini:"-"` Viewer ViewerConfig `ini:"-"` + Triggers TriggersConfig `ini:"-"` Ui UIConfig General GeneralConfig } @@ -278,6 +284,11 @@ func (config *AercConfig) LoadConfig(file *ini.File) error { return err } } + if triggers, err := file.GetSection("triggers"); err == nil { + if err := triggers.MapTo(&config.Triggers); err != nil { + return err + } + } return nil } diff --git a/config/triggers.go b/config/triggers.go new file mode 100644 index 0000000..d31f267 --- /dev/null +++ b/config/triggers.go @@ -0,0 +1,49 @@ +package config + +import ( + "errors" + "fmt" + + "github.com/google/shlex" + + "git.sr.ht/~sircmpwn/aerc/lib/format" + "git.sr.ht/~sircmpwn/aerc/models" +) + +func (trig *TriggersConfig) ExecTrigger(triggerCmd string, + triggerFmt func(string) (string, error)) error { + + if len(triggerCmd) == 0 { + return errors.New("Trigger command empty") + } + triggerCmdParts, err := shlex.Split(triggerCmd) + if err != nil { + return err + } + + var command []string + for _, part := range triggerCmdParts { + formattedPart, err := triggerFmt(part) + if err != nil { + return err + } + command = append(command, formattedPart) + } + return trig.ExecuteCommand(command) +} + +func (trig *TriggersConfig) ExecNewEmail(account *AccountConfig, + conf *AercConfig, msg *models.MessageInfo) { + err := trig.ExecTrigger(trig.NewEmail, + func(part string) (string, error) { + formatstr, args, err := format.ParseMessageFormat(part, + conf.Ui.TimestampFormat, account.Name, 0, msg) + if err != nil { + return "", err + } + return fmt.Sprintf(formatstr, args...), nil + }) + if err != nil { + fmt.Printf("Error from the new-email trigger: %s\n", err) + } +} diff --git a/doc/aerc-config.5.scd b/doc/aerc-config.5.scd index 665f4f4..08f65af 100644 --- a/doc/aerc-config.5.scd +++ b/doc/aerc-config.5.scd @@ -68,6 +68,10 @@ These options are configured in the *[ui]* section of aerc.conf. : comma-separated list of formatted CC names and addresses | %s : subject +| %t +: the (first) address the new email was sent to +| %T +: the account name which received the email | %u : sender mailbox name (e.g. "smith" in "smith@example.net") | %v @@ -164,6 +168,22 @@ aerc ships with some default filters installed in the share directory (usually _/usr/share/aerc/filters_). Note that these may have additional dependencies that aerc does not have alone. +## TRIGGERS + +Triggers specify commands to execute when certain events occur. + +They are configured in the *[triggers]* section of aerc.conf. + +*new-email* + Executed when a new email arrives in the selected folder. + + e.g. new-email=exec notify-send "New email from %n" "%s" + + Default: "" + + Format specifiers from *index-format* are expanded with respect to the new + message. + # ACCOUNTS.CONF This file is used for configuring each mail account used for aerc. Each section diff --git a/lib/indexformat.go b/lib/format/format.go similarity index 78% rename from lib/indexformat.go rename to lib/format/format.go index 34b4d77..b403f2d 100644 --- a/lib/indexformat.go +++ b/lib/format/format.go @@ -1,4 +1,4 @@ -package lib +package format import ( "errors" @@ -6,14 +6,12 @@ import ( "strings" "unicode" - "git.sr.ht/~sircmpwn/aerc/config" "git.sr.ht/~sircmpwn/aerc/models" ) -func ParseIndexFormat(conf *config.AercConfig, number int, - msg *models.MessageInfo) (string, []interface{}, error) { - - format := conf.Ui.IndexFormat +func ParseMessageFormat(format string, timestampformat string, + accountName string, number int, msg *models.MessageInfo) (string, + []interface{}, error) { retval := make([]byte, 0, len(format)) var args []interface{} @@ -64,11 +62,13 @@ func ParseIndexFormat(conf *config.AercConfig, number int, retval = append(retval, '%') case 'a': if len(msg.Envelope.From) == 0 { - return "", nil, errors.New("found no address for sender") + return "", nil, + errors.New("found no address for sender") } addr := msg.Envelope.From[0] retval = append(retval, 's') - args = append(args, fmt.Sprintf("%s@%s", addr.Mailbox, addr.Host)) + args = append(args, + fmt.Sprintf("%s@%s", addr.Mailbox, addr.Host)) case 'A': var addr *models.Address if len(msg.Envelope.ReplyTo) == 0 { @@ -82,26 +82,31 @@ func ParseIndexFormat(conf *config.AercConfig, number int, addr = msg.Envelope.ReplyTo[0] } retval = append(retval, 's') - args = append(args, fmt.Sprintf("%s@%s", addr.Mailbox, addr.Host)) + args = append(args, + fmt.Sprintf("%s@%s", addr.Mailbox, addr.Host)) case 'C': retval = append(retval, 'd') args = append(args, number) case 'd': retval = append(retval, 's') - args = append(args, msg.InternalDate.Format(conf.Ui.TimestampFormat)) + args = append(args, + msg.InternalDate.Format(timestampformat)) case 'D': retval = append(retval, 's') - args = append(args, msg.InternalDate.Local().Format(conf.Ui.TimestampFormat)) + args = append(args, + msg.InternalDate.Local().Format(timestampformat)) case 'f': if len(msg.Envelope.From) == 0 { - return "", nil, errors.New("found no address for sender") + return "", nil, + errors.New("found no address for sender") } addr := msg.Envelope.From[0].Format() retval = append(retval, 's') args = append(args, addr) case 'F': if len(msg.Envelope.From) == 0 { - return "", nil, errors.New("found no address for sender") + return "", nil, + errors.New("found no address for sender") } addr := msg.Envelope.From[0] // TODO: handle case when sender is current user. Then @@ -120,7 +125,8 @@ func ParseIndexFormat(conf *config.AercConfig, number int, args = append(args, msg.Envelope.MessageId) case 'n': if len(msg.Envelope.From) == 0 { - return "", nil, errors.New("found no address for sender") + return "", nil, + errors.New("found no address for sender") } addr := msg.Envelope.From[0] var val string @@ -142,22 +148,37 @@ func ParseIndexFormat(conf *config.AercConfig, number int, case 's': retval = append(retval, 's') args = append(args, msg.Envelope.Subject) + case 't': + if len(msg.Envelope.To) == 0 { + return "", nil, + errors.New("found no address for recipient") + } + addr := msg.Envelope.To[0] + retval = append(retval, 's') + args = append(args, + fmt.Sprintf("%s@%s", addr.Mailbox, addr.Host)) + case 'T': + retval = append(retval, 's') + args = append(args, accountName) case 'u': if len(msg.Envelope.From) == 0 { - return "", nil, errors.New("found no address for sender") + return "", nil, + errors.New("found no address for sender") } addr := msg.Envelope.From[0] retval = append(retval, 's') args = append(args, addr.Mailbox) case 'v': if len(msg.Envelope.From) == 0 { - return "", nil, errors.New("found no address for sender") + return "", nil, + errors.New("found no address for sender") } addr := msg.Envelope.From[0] // check if message is from current user if addr.Name != "" { retval = append(retval, 's') - args = append(args, strings.Split(addr.Name, " ")[0]) + args = append(args, + strings.Split(addr.Name, " ")[0]) } case 'Z': // calculate all flags @@ -237,5 +258,6 @@ func ParseIndexFormat(conf *config.AercConfig, number int, return string(retval), args, nil handle_end_error: - return "", nil, errors.New("reached end of string while parsing index format") + return "", nil, + errors.New("reached end of string while parsing message format") } diff --git a/lib/msgstore.go b/lib/msgstore.go index 736217e..53faaac 100644 --- a/lib/msgstore.go +++ b/lib/msgstore.go @@ -32,10 +32,13 @@ type MessageStore struct { pendingBodies map[uint32]interface{} pendingHeaders map[uint32]interface{} worker *types.Worker + + triggerNewEmail func(*models.MessageInfo) } func NewMessageStore(worker *types.Worker, - dirInfo *models.DirectoryInfo) *MessageStore { + dirInfo *models.DirectoryInfo, + triggerNewEmail func(*models.MessageInfo)) *MessageStore { return &MessageStore{ Deleted: make(map[uint32]interface{}), @@ -48,6 +51,8 @@ func NewMessageStore(worker *types.Worker, pendingBodies: make(map[uint32]interface{}), pendingHeaders: make(map[uint32]interface{}), worker: worker, + + triggerNewEmail: triggerNewEmail, } } @@ -165,6 +170,18 @@ func (store *MessageStore) Update(msg types.WorkerMessage) { } else { store.Messages[msg.Info.Uid] = msg.Info } + seen := false + recent := false + for _, flag := range msg.Info.Flags { + if flag == models.RecentFlag { + recent = true + } else if flag == models.SeenFlag { + seen = true + } + } + if !seen && recent { + store.triggerNewEmail(msg.Info) + } if _, ok := store.pendingHeaders[msg.Info.Uid]; msg.Info.Envelope != nil && ok { delete(store.pendingHeaders, msg.Info.Uid) if cbs, ok := store.headerCallbacks[msg.Info.Uid]; ok { diff --git a/widgets/account.go b/widgets/account.go index f070df1..92e7a56 100644 --- a/widgets/account.go +++ b/widgets/account.go @@ -203,7 +203,11 @@ func (acct *AccountView) onMessage(msg types.WorkerMessage) { if store, ok := acct.msgStores[msg.Info.Name]; ok { store.Update(msg) } else { - store = lib.NewMessageStore(acct.worker, msg.Info) + store = lib.NewMessageStore(acct.worker, msg.Info, + func(msg *models.MessageInfo) { + acct.conf.Triggers.ExecNewEmail(acct.acct, + acct.conf, msg) + }) acct.msgStores[msg.Info.Name] = store store.OnUpdate(func(_ *lib.MessageStore) { store.OnUpdate(nil) diff --git a/widgets/aerc.go b/widgets/aerc.go index 079d442..3cf1f64 100644 --- a/widgets/aerc.go +++ b/widgets/aerc.go @@ -8,6 +8,7 @@ import ( "time" "github.com/gdamore/tcell" + "github.com/google/shlex" "git.sr.ht/~sircmpwn/aerc/config" "git.sr.ht/~sircmpwn/aerc/lib/ui" @@ -16,7 +17,7 @@ import ( type Aerc struct { accounts map[string]*AccountView - cmd func(cmd string) error + cmd func(cmd []string) error complete func(cmd string) []string conf *config.AercConfig focused libui.Interactive @@ -30,7 +31,7 @@ type Aerc struct { } func NewAerc(conf *config.AercConfig, logger *log.Logger, - cmd func(cmd string) error, complete func(cmd string) []string) *Aerc { + cmd func(cmd []string) error, complete func(cmd string) []string) *Aerc { tabs := libui.NewTabs() @@ -62,6 +63,7 @@ func NewAerc(conf *config.AercConfig, logger *log.Logger, } statusline.SetAerc(aerc) + conf.Triggers.ExecuteCommand = cmd for i, acct := range conf.Accounts { view := NewAccountView(conf, &conf.Accounts[i], logger, aerc) @@ -311,7 +313,12 @@ func (aerc *Aerc) focus(item libui.Interactive) { func (aerc *Aerc) BeginExCommand() { previous := aerc.focused exline := NewExLine(func(cmd string) { - err := aerc.cmd(cmd) + parts, err := shlex.Split(cmd) + if err != nil { + aerc.PushStatus(" "+err.Error(), 10*time.Second). + Color(tcell.ColorDefault, tcell.ColorRed) + } + err = aerc.cmd(parts) if err != nil { aerc.PushStatus(" "+err.Error(), 10*time.Second). Color(tcell.ColorDefault, tcell.ColorRed) diff --git a/widgets/msglist.go b/widgets/msglist.go index e8ba8c1..abf6921 100644 --- a/widgets/msglist.go +++ b/widgets/msglist.go @@ -9,6 +9,7 @@ import ( "git.sr.ht/~sircmpwn/aerc/config" "git.sr.ht/~sircmpwn/aerc/lib" + "git.sr.ht/~sircmpwn/aerc/lib/format" "git.sr.ht/~sircmpwn/aerc/lib/ui" "git.sr.ht/~sircmpwn/aerc/models" ) @@ -95,7 +96,9 @@ func (ml *MessageList) Draw(ctx *ui.Context) { } ctx.Fill(0, row, ctx.Width(), 1, ' ', style) - fmtStr, args, err := lib.ParseIndexFormat(ml.conf, i, msg) + fmtStr, args, err := format.ParseMessageFormat( + ml.conf.Ui.IndexFormat, + ml.conf.Ui.TimestampFormat, "", i, msg) if err != nil { ctx.Printf(0, row, style, "%v", err) } else {