From 6c8f175beeeeec92e101d92efccf0c2177b79950 Mon Sep 17 00:00:00 2001 From: Maas Lalani Date: Tue, 18 Jul 2023 12:05:25 -0400 Subject: [PATCH 1/3] feat: add SMTP support Adds SMTP configuration via environment variables: * `POP_SMTP_HOST` * `POP_SMTP_PORT` * `POP_SMTP_USERNAME` * `POP_SMTP_PASSWORD` --- README.md | 26 ++++++++++- attachments.go | 2 +- email.go | 92 +++++++++++++++++++++++++++++++++++-- go.mod | 6 ++- go.sum | 12 ++++- go.work.sum | 21 ++++++++- keymap.go | 3 ++ main.go | 121 ++++++++++++++++++++++++++++++++++++++----------- model.go | 40 +++++++++++----- style.go | 2 +- 10 files changed, 275 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 4adde80..9c68228 100644 --- a/README.md +++ b/README.md @@ -36,17 +36,39 @@ pop < message.md \ Resend and Charm logos -To use `pop`, you will need a `RESEND_API_KEY`. +To use `pop`, you will need a `RESEND_API_KEY` or configure an +[`SMTP`](#smtp-configuration) host. You can grab one from: https://resend.com/api-keys. +### Resend Configuration + +To use the resend delivery method, set the `RESEND_API_KEY` environment +variable. + +```bash +export RESEND_API_KEY=$(pass RESEND_API_KEY) +``` + + +### SMTP Configuration + +To configure `pop` to use `SMTP`, you can set the following environment +variables. + +```bash +export POP_SMTP_HOST=smtp.gmail.com +export POP_SMTP_PORT=587 +export POP_SMTP_USERNAME=pop@charm.sh +export POP_SMTP_PASSWORD=hunter2 +``` + ### Environment To avoid typing your `From: ` email address, you can also set the `POP_FROM` environment to pre-fill the field anytime you launch `pop`. ```bash -export RESEND_API_KEY=$(pass RESEND_API_KEY) export POP_FROM=pop@charm.sh export POP_SIGNATURE="Sent with [Pop](https://github.com/charmbracelet/pop)!" ``` diff --git a/attachments.go b/attachments.go index be134da..3967d4c 100644 --- a/attachments.go +++ b/attachments.go @@ -40,6 +40,6 @@ func (d attachmentDelegate) Render(w io.Writer, m list.Model, index int, item li } } -func (d attachmentDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { +func (d attachmentDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } diff --git a/email.go b/email.go index 52c10bd..b00e5f2 100644 --- a/email.go +++ b/email.go @@ -2,18 +2,23 @@ package main import ( "bytes" + "crypto/tls" + "errors" "os" "path/filepath" "strings" + "time" tea "github.com/charmbracelet/bubbletea" "github.com/resendlabs/resend-go" + mail "github.com/xhit/go-simple-mail/v2" "github.com/yuin/goldmark" "github.com/yuin/goldmark/extension" renderer "github.com/yuin/goldmark/renderer/html" ) -const TO_SEPARATOR = "," +// ToSeparator is the separator used to split the To, Cc, and Bcc fields. +const ToSeparator = "," // sendEmailSuccessMsg is the tea.Msg handled by Bubble Tea when the email has // been sent successfully. @@ -30,7 +35,18 @@ func (m Model) sendEmailCmd() tea.Cmd { for i, a := range m.Attachments.Items() { attachments[i] = a.FilterValue() } - err := sendEmail(strings.Split(m.To.Value(), TO_SEPARATOR), m.From.Value(), m.Subject.Value(), m.Body.Value(), attachments) + var err error + to := strings.Split(m.To.Value(), ToSeparator) + cc := strings.Split(m.Cc.Value(), ToSeparator) + bcc := strings.Split(m.Bcc.Value(), ToSeparator) + switch m.DeliveryMethod { + case SMTP: + err = sendSMTPEmail(to, cc, bcc, m.From.Value(), m.Subject.Value(), m.Body.Value(), attachments) + case Resend: + err = sendResendEmail(to, cc, bcc, m.From.Value(), m.Subject.Value(), m.Body.Value(), attachments) + default: + err = errors.New("[ERROR]: unknown delivery method") + } if err != nil { return sendEmailFailureMsg(err) } @@ -38,8 +54,72 @@ func (m Model) sendEmailCmd() tea.Cmd { } } -func sendEmail(to []string, from, subject, body string, paths []string) error { - client := resend.NewClient(os.Getenv(RESEND_API_KEY)) +const gmailSuffix = "@gmail.com" +const gmailSMTPHost = "smtp.gmail.com" +const gmailSMTPPort = 587 + +func sendSMTPEmail(to, cc, bcc []string, from, subject, body string, attachments []string) error { + server := mail.NewSMTPClient() + + var err error + server.Username = smtpUsername + server.Password = smtpPassword + server.Host = smtpHost + server.Port = smtpPort + + // Set defaults for gmail. + if strings.HasSuffix(server.Username, gmailSuffix) { + if server.Port == 0 { + server.Port = gmailSMTPPort + } + if server.Host == "" { + server.Host = gmailSMTPHost + } + } + + server.Encryption = mail.EncryptionSTARTTLS + server.KeepAlive = false + server.ConnectTimeout = 10 * time.Second + server.SendTimeout = 10 * time.Second + server.TLSConfig = &tls.Config{ + InsecureSkipVerify: smtpInsecureSkipVerify, //nolint:gosec + ServerName: server.Host, + } + + smtpClient, err := server.Connect() + + if err != nil { + return err + } + + email := mail.NewMSG() + email.SetFrom(from). + AddTo(to...). + AddCc(cc...). + AddBcc(bcc...). + SetSubject(subject) + + html := bytes.NewBufferString("") + convertErr := goldmark.Convert([]byte(body), html) + + if convertErr != nil { + email.SetBody(mail.TextPlain, body) + } else { + email.SetBody(mail.TextHTML, html.String()) + } + + for _, a := range attachments { + email.Attach(&mail.File{ + FilePath: a, + Name: filepath.Base(a), + }) + } + + return email.Send(smtpClient) +} + +func sendResendEmail(to, cc, bcc []string, from, subject, body string, attachments []string) error { + client := resend.NewClient(resendAPIKey) html := bytes.NewBufferString("") // If the conversion fails, we'll simply send the plain-text body. @@ -62,10 +142,12 @@ func sendEmail(to []string, from, subject, body string, paths []string) error { request := &resend.SendEmailRequest{ From: from, To: to, + Cc: cc, + Bcc: bcc, Subject: subject, Html: html.String(), Text: body, - Attachments: makeAttachments(paths), + Attachments: makeAttachments(attachments), } _, err := client.Emails.Send(request) diff --git a/go.mod b/go.mod index f727707..fb3ed90 100644 --- a/go.mod +++ b/go.mod @@ -6,12 +6,13 @@ require ( github.com/charmbracelet/bubbles v0.16.2-0.20230711184233-0bdcc628fb8f github.com/charmbracelet/bubbletea v0.24.3-0.20230710130425-c4c83ba757f8 github.com/charmbracelet/lipgloss v0.7.1 + github.com/charmbracelet/x/exp/ordered v0.0.0-20230707174939-50fb4f48b5b3 github.com/muesli/mango-cobra v1.2.0 github.com/muesli/roff v0.1.0 github.com/resendlabs/resend-go v1.7.0 github.com/spf13/cobra v1.7.0 + github.com/xhit/go-simple-mail/v2 v2.15.0 github.com/yuin/goldmark v1.5.5 - golang.org/x/exp v0.0.0-20230711153332-06a737ee72cb ) require ( @@ -19,6 +20,7 @@ require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-test/deep v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.19 // indirect @@ -33,6 +35,8 @@ require ( github.com/rivo/uniseg v0.4.4 // indirect github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f // indirect github.com/spf13/pflag v1.0.5 // indirect + github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 // indirect + golang.org/x/exp v0.0.0-20230510235704-dd950f8aeaea // indirect golang.org/x/sync v0.3.0 // indirect golang.org/x/sys v0.10.0 // indirect golang.org/x/term v0.10.0 // indirect diff --git a/go.sum b/go.sum index 1fa9c79..cdbe484 100644 --- a/go.sum +++ b/go.sum @@ -8,12 +8,16 @@ github.com/charmbracelet/bubbletea v0.24.3-0.20230710130425-c4c83ba757f8 h1:rPWh github.com/charmbracelet/bubbletea v0.24.3-0.20230710130425-c4c83ba757f8/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg= github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZpc5Kc1E= github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c= +github.com/charmbracelet/x/exp/ordered v0.0.0-20230707174939-50fb4f48b5b3 h1:n1M8YLRcevMcCxr3vdLjtcrwVBwQpnbZU9IWvTxNhXw= +github.com/charmbracelet/x/exp/ordered v0.0.0-20230707174939-50fb4f48b5b3/go.mod h1:PHXDBVg6d66dpDTqESmefHTluiCgsCWPNtXA6g1ePGU= github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg= +github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= @@ -57,10 +61,14 @@ github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRM github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= +github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 h1:PM5hJF7HVfNWmCjMdEfbuOBNXSVF2cMFGgQTPdKCbwM= +github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns= +github.com/xhit/go-simple-mail/v2 v2.15.0 h1:qMXeqcZErUW/Dw6EXxmPuxHzVI8MdxWnEnu2xcisohU= +github.com/xhit/go-simple-mail/v2 v2.15.0/go.mod h1:b7P5ygho6SYE+VIqpxA6QkYfv4teeyG4MKqB3utRu98= github.com/yuin/goldmark v1.5.5 h1:IJznPe8wOzfIKETmMkd06F8nXkmlhaHqFRM9l1hAGsU= github.com/yuin/goldmark v1.5.5/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -golang.org/x/exp v0.0.0-20230711153332-06a737ee72cb h1:xIApU0ow1zwMa2uL1VDNeQlNVFTWMQxZUZCMDy0Q4Us= -golang.org/x/exp v0.0.0-20230711153332-06a737ee72cb/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= +golang.org/x/exp v0.0.0-20230510235704-dd950f8aeaea h1:vLCWI/yYrdEHyN2JzIzPO3aaQJHQdp89IZBA/+azVC4= +golang.org/x/exp v0.0.0-20230510235704-dd950f8aeaea/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/go.work.sum b/go.work.sum index 63f7b8f..7a24069 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1 +1,20 @@ -github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 h1:PM5hJF7HVfNWmCjMdEfbuOBNXSVF2cMFGgQTPdKCbwM= +github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns= +github.com/xhit/go-simple-mail/v2 v2.15.0 h1:qMXeqcZErUW/Dw6EXxmPuxHzVI8MdxWnEnu2xcisohU= +github.com/xhit/go-simple-mail/v2 v2.15.0/go.mod h1:b7P5ygho6SYE+VIqpxA6QkYfv4teeyG4MKqB3utRu98= +github.com/yuin/goldmark v1.5.4/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.5.5 h1:IJznPe8wOzfIKETmMkd06F8nXkmlhaHqFRM9l1hAGsU= +github.com/yuin/goldmark v1.5.5/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/exp v0.0.0-20230711153332-06a737ee72cb h1:xIApU0ow1zwMa2uL1VDNeQlNVFTWMQxZUZCMDy0Q4Us= +golang.org/x/exp v0.0.0-20230711153332-06a737ee72cb/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= +golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA= +golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.10.0 h1:3R7pNqamzBraeqj/Tj8qt1aQ2HpmlC+Cx/qL/7hn4/c= +golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o= +golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/keymap.go b/keymap.go index 948348e..234971e 100644 --- a/keymap.go +++ b/keymap.go @@ -13,6 +13,7 @@ type KeyMap struct { Quit key.Binding } +// DefaultKeybinds returns the default key bindings for the application. func DefaultKeybinds() KeyMap { return KeyMap{ NextInput: key.NewBinding( @@ -49,6 +50,7 @@ func DefaultKeybinds() KeyMap { } } +// ShortHelp returns the key bindings for the short help screen. func (k KeyMap) ShortHelp() []key.Binding { return []key.Binding{ k.NextInput, @@ -59,6 +61,7 @@ func (k KeyMap) ShortHelp() []key.Binding { } } +// FullHelp returns the key bindings for the full help screen. func (k KeyMap) FullHelp() [][]key.Binding { return [][]key.Binding{ {k.NextInput, k.Send, k.Attach, k.Unattach, k.Quit}, diff --git a/main.go b/main.go index afdbc88..11ac2d2 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,7 @@ import ( "io" "os" "runtime/debug" + "strconv" "strings" tea "github.com/charmbracelet/bubbletea" @@ -14,20 +15,53 @@ import ( "github.com/spf13/cobra" ) -const RESEND_API_KEY = "RESEND_API_KEY" -const UNSAFE_HTML = "POP_UNSAFE_HTML" -const POP_FROM = "POP_FROM" -const POP_SIGNATURE = "POP_SIGNATURE" +// PopUnsafeHTML is the environment variable that enables unsafe HTML in the +// email body. +const PopUnsafeHTML = "POP_UNSAFE_HTML" + +// ResendAPIKey is the environment variable that enables Resend as a delivery +// method and uses it to send the email. +const ResendAPIKey = "RESEND_API_KEY" //nolint:gosec + +// PopFrom is the environment variable that sets the default "from" address. +const PopFrom = "POP_FROM" + +// PopSignature is the environment variable that sets the default signature. +const PopSignature = "POP_SIGNATURE" + +// PopSMTPHost is the host for the SMTP server if the user is using the SMTP delivery method. +const PopSMTPHost = "POP_SMTP_HOST" + +// PopSMTPPort is the port for the SMTP server if the user is using the SMTP delivery method. +const PopSMTPPort = "POP_SMTP_PORT" + +// PopSMTPUsername is the username for the SMTP server if the user is using the SMTP delivery method. +const PopSMTPUsername = "POP_SMTP_USERNAME" + +// PopSMTPPassword is the password for the SMTP server if the user is using the SMTP delivery method. +const PopSMTPPassword = "POP_SMTP_PASSWORD" //nolint:gosec + +// PopSMTPInsecureSkipVerify is whether or not to skip TLS verification for the +// SMTP server if the user is using the SMTP delivery method. +const PopSMTPInsecureSkipVerify = "POP_SMTP_INSECURE_SKIP_VERIFY" var ( - from string - to []string - subject string - body string - attachments []string - preview bool - unsafe bool - signature string + from string + to []string + cc []string + bcc []string + subject string + body string + attachments []string + preview bool + unsafe bool + signature string + smtpHost string + smtpPort int + smtpUsername string + smtpPassword string + smtpInsecureSkipVerify bool + resendAPIKey string ) var rootCmd = &cobra.Command{ @@ -35,10 +69,19 @@ var rootCmd = &cobra.Command{ Short: "Send emails from your terminal", Long: `Pop is a tool for sending emails from your terminal.`, RunE: func(cmd *cobra.Command, args []string) error { - if os.Getenv(RESEND_API_KEY) == "" { - fmt.Printf("\n %s %s %s\n\n", errorHeaderStyle.String(), inlineCodeStyle.Render(RESEND_API_KEY), "environment variable is required.") + var deliveryMethod DeliveryMethod + switch { + case resendAPIKey != "": + deliveryMethod = Resend + case smtpUsername != "" && smtpPassword != "": + deliveryMethod = SMTP + from = smtpUsername + } + + if deliveryMethod == None { + fmt.Printf("\n %s %s %s\n\n", errorHeaderStyle.String(), inlineCodeStyle.Render(ResendAPIKey), "environment variable is required.") fmt.Printf(" %s %s\n\n", commentStyle.Render("You can grab one at"), linkStyle.Render("https://resend.com/api-keys")) - os.Exit(1) + return nil } if hasStdin() { @@ -54,7 +97,15 @@ var rootCmd = &cobra.Command{ } if len(to) > 0 && from != "" && subject != "" && body != "" && !preview { - err := sendEmail(to, from, subject, body, attachments) + var err error + switch deliveryMethod { + case SMTP: + err = sendSMTPEmail(to, cc, bcc, from, subject, body, attachments) + case Resend: + err = sendResendEmail(to, cc, bcc, from, subject, body, attachments) + default: + err = fmt.Errorf("unknown delivery method") + } if err != nil { cmd.SilenceUsage = true cmd.SilenceErrors = true @@ -71,14 +122,15 @@ var rootCmd = &cobra.Command{ Subject: subject, Text: body, Attachments: makeAttachments(attachments), - })) + }, deliveryMethod)) + m, err := p.Run() if err != nil { return err } mm := m.(Model) if !mm.abort { - fmt.Print(emailSummary(strings.Split(mm.To.Value(), TO_SEPARATOR), mm.Subject.Value())) + fmt.Print(emailSummary(strings.Split(mm.To.Value(), ToSeparator), mm.Subject.Value())) } return nil }, @@ -101,6 +153,7 @@ var ( CommitSHA string ) +// CompletionCmd is the cobra command for generating completion scripts. var CompletionCmd = &cobra.Command{ Use: "completion [bash|zsh|fish|powershell]", Short: "Generate completion script", @@ -123,6 +176,7 @@ var CompletionCmd = &cobra.Command{ }, } +// ManCmd is the cobra command for the manual. var ManCmd = &cobra.Command{ Use: "man", Short: "Generate man page", @@ -144,19 +198,34 @@ var ManCmd = &cobra.Command{ func init() { rootCmd.AddCommand(CompletionCmd, ManCmd) - rootCmd.Flags().StringSliceVar(&to, "bcc", []string{}, "BCC recipients") - rootCmd.Flags().StringSliceVar(&to, "cc", []string{}, "CC recipients") + rootCmd.Flags().StringSliceVar(&bcc, "bcc", []string{}, "BCC recipients") + rootCmd.Flags().StringSliceVar(&cc, "cc", []string{}, "CC recipients") rootCmd.Flags().StringSliceVarP(&attachments, "attach", "a", []string{}, "Email's attachments") rootCmd.Flags().StringSliceVarP(&to, "to", "t", []string{}, "Recipients") rootCmd.Flags().StringVarP(&body, "body", "b", "", "Email's contents") - envFrom := os.Getenv(POP_FROM) - rootCmd.Flags().StringVarP(&from, "from", "f", envFrom, "Email's sender "+commentStyle.Render("($"+POP_FROM+")")) + envFrom := os.Getenv(PopFrom) + rootCmd.Flags().StringVarP(&from, "from", "f", envFrom, "Email's sender"+commentStyle.Render("($"+PopFrom+")")) rootCmd.Flags().StringVarP(&subject, "subject", "s", "", "Email's subject") - rootCmd.Flags().BoolVarP(&preview, "preview", "p", false, "Whether to preview the email before sending") - envUnsafe := os.Getenv(UNSAFE_HTML) == "true" + rootCmd.Flags().BoolVar(&preview, "preview", false, "Whether to preview the email before sending") + envUnsafe := os.Getenv(PopUnsafeHTML) == "true" rootCmd.Flags().BoolVarP(&unsafe, "unsafe", "u", envUnsafe, "Whether to allow unsafe HTML in the email body, also enable some extra markdown features (Experimental)") - envSignature := os.Getenv("POP_SIGNATURE") - rootCmd.Flags().StringVarP(&signature, "signature", "x", envSignature, "Signature to display at the end of the email. "+commentStyle.Render("($"+POP_SIGNATURE+")")) + envSignature := os.Getenv(PopSignature) + rootCmd.Flags().StringVarP(&signature, "signature", "x", envSignature, "Signature to display at the end of the email."+commentStyle.Render("($"+PopSignature+")")) + envSMTPHost := os.Getenv(PopSMTPHost) + rootCmd.Flags().StringVarP(&smtpHost, "smtp.host", "H", envSMTPHost, "Host of the SMTP server"+commentStyle.Render("($"+PopSMTPHost+")")) + envSMTPPort, _ := strconv.Atoi(os.Getenv(PopSMTPPort)) + if envSMTPPort == 0 { + envSMTPPort = 587 + } + rootCmd.Flags().IntVarP(&smtpPort, "smtp.port", "P", envSMTPPort, "Port of the SMTP server"+commentStyle.Render("($"+PopSMTPPort+")")) + envSMTPUsername := os.Getenv(PopSMTPUsername) + rootCmd.Flags().StringVarP(&smtpUsername, "smtp.username", "U", envSMTPUsername, "Username of the SMTP server"+commentStyle.Render("($"+PopSMTPUsername+")")) + envSMTPPassword := os.Getenv(PopSMTPPassword) + rootCmd.Flags().StringVarP(&smtpPassword, "smtp.password", "p", envSMTPPassword, "Password of the SMTP server"+commentStyle.Render("($"+PopSMTPPassword+")")) + envInsecureSkipVerify := os.Getenv(PopSMTPInsecureSkipVerify) == "true" + rootCmd.Flags().BoolVarP(&smtpInsecureSkipVerify, "smtp.insecure", "i", envInsecureSkipVerify, "Skip TLS verification with SMTP server"+commentStyle.Render("($"+PopSMTPInsecureSkipVerify+")")) + envResendAPIKey := os.Getenv(ResendAPIKey) + rootCmd.Flags().StringVarP(&resendAPIKey, "resend.key", "r", envResendAPIKey, "API key for the Resend.com"+commentStyle.Render("($"+ResendAPIKey+")")) rootCmd.CompletionOptions.HiddenDefaultCmd = true diff --git a/model.go b/model.go index 4656f66..6488fbe 100644 --- a/model.go +++ b/model.go @@ -13,10 +13,11 @@ import ( "github.com/charmbracelet/bubbles/textarea" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/exp/ordered" "github.com/resendlabs/resend-go" - "golang.org/x/exp/constraints" ) +// State is the current state of the application. type State int const ( @@ -30,10 +31,26 @@ const ( sendingEmail ) +// DeliveryMethod is the method of delivery for the email. +type DeliveryMethod int + +const ( + // None is the default delivery method. + None DeliveryMethod = iota + // Resend uses https://resend.com to send an email. + Resend + // SMTP uses an SMTP server to send an email. + SMTP +) + +// Model is Pop's application model. type Model struct { // state represents the current state of the application. state State + // DeliveryMethod is whether we are using DeliveryMethod or Resend. + DeliveryMethod DeliveryMethod + // From represents the sender's email address. From textinput.Model // To represents the recipient's email address. @@ -48,6 +65,9 @@ type Model struct { // This is a list of file paths which are picked with a filepicker. Attachments list.Model + Cc textinput.Model + Bcc textinput.Model + // filepicker is used to pick file attachments. filepicker filepicker.Model loadingSpinner spinner.Model @@ -58,7 +78,8 @@ type Model struct { err error } -func NewModel(defaults resend.SendEmailRequest) Model { +// NewModel returns a new model for the application. +func NewModel(defaults resend.SendEmailRequest, deliveryMethod DeliveryMethod) Model { from := textinput.New() from.Prompt = "From " from.Placeholder = "me@example.com" @@ -76,7 +97,7 @@ func NewModel(defaults resend.SendEmailRequest) Model { to.PlaceholderStyle = placeholderStyle to.TextStyle = textStyle to.Placeholder = "you@example.com" - to.SetValue(strings.Join(defaults.To, TO_SEPARATOR)) + to.SetValue(strings.Join(defaults.To, ToSeparator)) subject := textinput.New() subject.Prompt = "Subject " @@ -155,6 +176,7 @@ func NewModel(defaults resend.SendEmailRequest) Model { help: help.New(), keymap: DefaultKeybinds(), loadingSpinner: loadingSpinner, + DeliveryMethod: deliveryMethod, } m.focusActiveInput() @@ -162,6 +184,7 @@ func NewModel(defaults resend.SendEmailRequest) Model { return m } +// Init initializes the model. func (m Model) Init() tea.Cmd { return tea.Batch( m.From.Cursor.BlinkCmd(), @@ -176,6 +199,7 @@ func clearErrAfter(d time.Duration) tea.Cmd { }) } +// Update is the update loop for the model. func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case sendEmailSuccessMsg: @@ -243,7 +267,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.filepicker.Init() case key.Matches(msg, m.keymap.Unattach): m.Attachments.RemoveItem(m.Attachments.Index()) - m.Attachments.SetHeight(max(len(m.Attachments.Items()), 1) + 2) + m.Attachments.SetHeight(ordered.Max(len(m.Attachments.Items()), 1) + 2) case key.Matches(msg, m.keymap.Quit): m.quitting = true m.abort = true @@ -329,6 +353,7 @@ func (m *Model) focusActiveInput() { } } +// View displays the application. func (m Model) View() string { if m.quitting { return "" @@ -371,10 +396,3 @@ func (m Model) View() string { return paddedStyle.Render(s.String()) } - -func max[T constraints.Ordered](a, b T) T { - if a > b { - return a - } - return b -} diff --git a/style.go b/style.go index b2934cf..99ad181 100644 --- a/style.go +++ b/style.go @@ -27,7 +27,7 @@ var ( errorHeaderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#F1F1F1")).Background(lipgloss.Color("#FF5F87")).Bold(true).Padding(0, 1).SetString("ERROR") errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5F87")) - commentStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#757575")) + commentStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#757575")).PaddingLeft(1) sendButtonActiveStyle = lipgloss.NewStyle().Background(accentColor).Foreground(yellowColor).Padding(0, 2) sendButtonInactiveStyle = lipgloss.NewStyle().Background(darkGrayColor).Foreground(lightGrayColor).Padding(0, 2) From a2b7c74d5c996b3fb791df51d0abdad36d65cc95 Mon Sep 17 00:00:00 2001 From: Christian Muehlhaeuser Date: Fri, 28 Jul 2023 15:14:48 +0200 Subject: [PATCH 2/3] feat: add support for SSL connections to SMTP --- email.go | 7 ++++++- main.go | 6 ++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/email.go b/email.go index b00e5f2..4469a3f 100644 --- a/email.go +++ b/email.go @@ -77,7 +77,12 @@ func sendSMTPEmail(to, cc, bcc []string, from, subject, body string, attachments } } - server.Encryption = mail.EncryptionSTARTTLS + if strings.EqualFold(smtpEncryption, "ssl") { + server.Encryption = mail.EncryptionSSLTLS + } else { + server.Encryption = mail.EncryptionSTARTTLS + } + server.KeepAlive = false server.ConnectTimeout = 10 * time.Second server.SendTimeout = 10 * time.Second diff --git a/main.go b/main.go index 11ac2d2..fadd044 100644 --- a/main.go +++ b/main.go @@ -41,6 +41,9 @@ const PopSMTPUsername = "POP_SMTP_USERNAME" // PopSMTPPassword is the password for the SMTP server if the user is using the SMTP delivery method. const PopSMTPPassword = "POP_SMTP_PASSWORD" //nolint:gosec +// PopSMTPEncryption is the encryption type for the SMTP server if the user is using the SMTP delivery method. +const PopSMTPEncryption = "POP_SMTP_ENCRYPTION" //nolint:gosec + // PopSMTPInsecureSkipVerify is whether or not to skip TLS verification for the // SMTP server if the user is using the SMTP delivery method. const PopSMTPInsecureSkipVerify = "POP_SMTP_INSECURE_SKIP_VERIFY" @@ -60,6 +63,7 @@ var ( smtpPort int smtpUsername string smtpPassword string + smtpEncryption string smtpInsecureSkipVerify bool resendAPIKey string ) @@ -222,6 +226,8 @@ func init() { rootCmd.Flags().StringVarP(&smtpUsername, "smtp.username", "U", envSMTPUsername, "Username of the SMTP server"+commentStyle.Render("($"+PopSMTPUsername+")")) envSMTPPassword := os.Getenv(PopSMTPPassword) rootCmd.Flags().StringVarP(&smtpPassword, "smtp.password", "p", envSMTPPassword, "Password of the SMTP server"+commentStyle.Render("($"+PopSMTPPassword+")")) + envSMTPEncryption := os.Getenv(PopSMTPEncryption) + rootCmd.Flags().StringVarP(&smtpEncryption, "smtp.encryption", "e", envSMTPEncryption, "Encryption type of the SMTP server"+commentStyle.Render("($"+PopSMTPEncryption+")")) envInsecureSkipVerify := os.Getenv(PopSMTPInsecureSkipVerify) == "true" rootCmd.Flags().BoolVarP(&smtpInsecureSkipVerify, "smtp.insecure", "i", envInsecureSkipVerify, "Skip TLS verification with SMTP server"+commentStyle.Render("($"+PopSMTPInsecureSkipVerify+")")) envResendAPIKey := os.Getenv(ResendAPIKey) From 3e9958709532193ffca10ea31382df0b8fe6227e Mon Sep 17 00:00:00 2001 From: Christian Muehlhaeuser Date: Mon, 31 Jul 2023 15:10:54 +0200 Subject: [PATCH 3/3] feat: add support for unencrypted SMTP connections --- email.go | 7 +++++-- main.go | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/email.go b/email.go index 4469a3f..abf6338 100644 --- a/email.go +++ b/email.go @@ -77,9 +77,12 @@ func sendSMTPEmail(to, cc, bcc []string, from, subject, body string, attachments } } - if strings.EqualFold(smtpEncryption, "ssl") { + switch strings.ToLower(smtpEncryption) { + case "ssl": server.Encryption = mail.EncryptionSSLTLS - } else { + case "none": + server.Encryption = mail.EncryptionNone + default: server.Encryption = mail.EncryptionSTARTTLS } diff --git a/main.go b/main.go index fadd044..1c019d5 100644 --- a/main.go +++ b/main.go @@ -227,7 +227,7 @@ func init() { envSMTPPassword := os.Getenv(PopSMTPPassword) rootCmd.Flags().StringVarP(&smtpPassword, "smtp.password", "p", envSMTPPassword, "Password of the SMTP server"+commentStyle.Render("($"+PopSMTPPassword+")")) envSMTPEncryption := os.Getenv(PopSMTPEncryption) - rootCmd.Flags().StringVarP(&smtpEncryption, "smtp.encryption", "e", envSMTPEncryption, "Encryption type of the SMTP server"+commentStyle.Render("($"+PopSMTPEncryption+")")) + rootCmd.Flags().StringVarP(&smtpEncryption, "smtp.encryption", "e", envSMTPEncryption, "Encryption type of the SMTP server (starttls, ssl, or none)"+commentStyle.Render("($"+PopSMTPEncryption+")")) envInsecureSkipVerify := os.Getenv(PopSMTPInsecureSkipVerify) == "true" rootCmd.Flags().BoolVarP(&smtpInsecureSkipVerify, "smtp.insecure", "i", envInsecureSkipVerify, "Skip TLS verification with SMTP server"+commentStyle.Render("($"+PopSMTPInsecureSkipVerify+")")) envResendAPIKey := os.Getenv(ResendAPIKey)