├── examples ├── README.md ├── message.md ├── contacts.txt ├── invoice-example.tape ├── cli.tape ├── gum-example.tape ├── mods-example.tape └── demo.tape ├── .github ├── CODEOWNERS ├── workflows │ ├── build.yml │ ├── nightly.yml │ ├── dependabot-sync.yml │ ├── lint.yml │ └── goreleaser.yml ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── dependabot.yml ├── .gitignore ├── Dockerfile ├── CONTRIBUTING.md ├── .golangci.yml ├── .goreleaser.yml ├── .golangci-soft.yml ├── attachments.go ├── LICENSE ├── go.mod ├── style.go ├── keymap.go ├── README.md ├── email.go ├── go.sum ├── main.go └── model.go /examples/README.md: -------------------------------------------------------------------------------- 1 | # Pop 2 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @maaslalani 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gif 2 | *.pdf 3 | dist 4 | completions 5 | manpages 6 | -------------------------------------------------------------------------------- /examples/message.md: -------------------------------------------------------------------------------- 1 | # Pop 2 | 3 | Send **emails** from the _terminal_. 4 | 5 | Sincerely, 6 | Charm_ 7 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM gcr.io/distroless/static 2 | COPY pop /usr/local/bin/pop 3 | ENTRYPOINT [ "/usr/local/bin/pop" ] 4 | -------------------------------------------------------------------------------- /examples/contacts.txt: -------------------------------------------------------------------------------- 1 | ayman@charm.sh 2 | bash@charm.sh 3 | carlos@charm.sh 4 | christian@charm.sh 5 | jz@charm.sh 6 | maas@charm.sh 7 | muesli@charm.sh 8 | pop@charm.sh 9 | toby@charm.sh 10 | vt100@charm.sh 11 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | uses: charmbracelet/meta/.github/workflows/build.yml@main 8 | 9 | snapshot: 10 | uses: charmbracelet/meta/.github/workflows/snapshot.yml@main 11 | secrets: 12 | goreleaser_key: ${{ secrets.GORELEASER_KEY }} 13 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are welcome! 4 | 5 | Please submit a pull request for minor changes and submit issues for major changes for discussions. 6 | 7 | ### Testing 8 | 9 | Please test the following when submitting a change: 10 | 11 | 1. Sending an email with the TUI 12 | 2. Sending an email with the CLI 13 | -------------------------------------------------------------------------------- /.github/workflows/nightly.yml: -------------------------------------------------------------------------------- 1 | name: nightly 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | nightly: 10 | uses: charmbracelet/meta/.github/workflows/nightly.yml@main 11 | secrets: 12 | docker_username: ${{ secrets.DOCKERHUB_USERNAME }} 13 | docker_token: ${{ secrets.DOCKERHUB_TOKEN }} 14 | goreleaser_key: ${{ secrets.GORELEASER_KEY }} 15 | -------------------------------------------------------------------------------- /examples/invoice-example.tape: -------------------------------------------------------------------------------- 1 | Output invoice-example.gif 2 | Set Framerate 30 3 | Set FontSize 28 4 | Set Padding 40 5 | Set Width 1200 6 | Set Height 800 7 | 8 | Hide 9 | Type "export RESEND_API_KEY=$(pass RESEND_CHARM_API_KEY)" 10 | Enter 11 | Sleep 1 12 | Ctrl+L 13 | Show 14 | 15 | 16 | Type `invoice generate -i "Rubber Ducky" -r 25 -q 2 -o invoice.pdf` Enter 17 | Sleep 1s 18 | Type `pop --attach invoice.pdf --body "See attached invoice."` Enter 19 | Sleep 5s 20 | -------------------------------------------------------------------------------- /examples/cli.tape: -------------------------------------------------------------------------------- 1 | Output cli.gif 2 | Set Framerate 30 3 | Set Padding 70 4 | Set FontSize 38 5 | Set Width 1200 6 | Set Height 800 7 | 8 | Hide 9 | Type "export RESEND_API_KEY=$(pass RESEND_CHARM_API_KEY)" 10 | Enter 11 | Sleep 1 12 | Ctrl+L 13 | Show 14 | 15 | Type "pop < message.md \" Enter 16 | Type " --from 'pop@charm.sh' \" Enter 17 | Type " --to 'vt100@charm.sh' \" Enter 18 | Type " --subject 'Pop' \" Enter 19 | Type " --attach README.md" 20 | Sleep 0.5 21 | Enter 22 | Sleep 5 23 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-sync.yml: -------------------------------------------------------------------------------- 1 | name: dependabot-sync 2 | on: 3 | schedule: 4 | - cron: "0 0 * * 0" # every Sunday at midnight 5 | workflow_dispatch: # allows manual triggering 6 | 7 | permissions: 8 | contents: write 9 | pull-requests: write 10 | 11 | jobs: 12 | dependabot-sync: 13 | uses: charmbracelet/meta/.github/workflows/dependabot-sync.yml@main 14 | with: 15 | repo_name: ${{ github.event.repository.name }} 16 | secrets: 17 | gh_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} 18 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | tests: false 3 | 4 | issues: 5 | include: 6 | - EXC0001 7 | - EXC0005 8 | - EXC0011 9 | - EXC0012 10 | - EXC0013 11 | 12 | max-issues-per-linter: 0 13 | max-same-issues: 0 14 | 15 | linters: 16 | enable: 17 | - bodyclose 18 | - exportloopref 19 | - goimports 20 | - gosec 21 | - nilerr 22 | - predeclared 23 | - revive 24 | - rowserrcheck 25 | - sqlclosecheck 26 | - tparallel 27 | - unconvert 28 | - unparam 29 | - whitespace 30 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json 2 | 3 | includes: 4 | - from_url: 5 | url: charmbracelet/meta/main/goreleaser-full.yaml 6 | 7 | variables: 8 | main: "." 9 | binary_name: pop 10 | description: "Send emails from your terminal. 📬" 11 | github_url: "https://github.com/charmbracelet/pop" 12 | maintainer: "Maas Lalani " 13 | brew_commit_author_name: "Maas Lalani" 14 | brew_commit_author_email: "maas@charm.sh" 15 | aur_project_name: charm-pop 16 | -------------------------------------------------------------------------------- /examples/gum-example.tape: -------------------------------------------------------------------------------- 1 | Output gum-example.gif 2 | Set Framerate 30 3 | Set FontSize 28 4 | Set Padding 40 5 | Set Width 1200 6 | Set Height 800 7 | 8 | Hide 9 | Type "export RESEND_API_KEY=$(pass RESEND_CHARM_API_KEY)" 10 | Enter 11 | Sleep 1 12 | Ctrl+L 13 | Show 14 | 15 | 16 | Type `pop --from "$(gum choose vt{52,78,100}@charm.sh)" \` Enter 17 | Type ` --to "$(gum filter < contacts.txt)"` Enter 18 | 19 | Sleep 1s 20 | 21 | Down@200ms 2 22 | Sleep 1s 23 | Enter 24 | Sleep 1s 25 | 26 | Type "pop" 27 | Sleep 1s 28 | Enter 29 | 30 | Sleep 5s 31 | -------------------------------------------------------------------------------- /examples/mods-example.tape: -------------------------------------------------------------------------------- 1 | Output mods-example.gif 2 | Set Framerate 30 3 | Set FontSize 28 4 | Set Padding 40 5 | Set Width 1200 6 | Set Height 800 7 | 8 | Hide 9 | Type "export RESEND_API_KEY=$(pass RESEND_CHARM_API_KEY)" 10 | Enter 11 | Sleep 1 12 | Type "export OPENAI_API_KEY=$(pass OPENAI_API_KEY)" 13 | Enter 14 | Sleep 1 15 | Ctrl+L 16 | Show 17 | 18 | 19 | Type `pop <<< "$(mods -f 'Explain why CLIs are awesome')" \` Enter 20 | Type ` --subject "The command line is the best" \` Enter 21 | Type ` --preview` Enter 22 | 23 | Sleep 5s 24 | 25 | Hide 26 | Sleep 60s 27 | Show 28 | 29 | Sleep 5s 30 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | on: 3 | push: 4 | pull_request: 5 | 6 | jobs: 7 | golangci: 8 | name: lint 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v6 12 | - uses: actions/setup-go@v6 13 | with: 14 | go-version: ^1 15 | - name: golangci-lint 16 | uses: golangci/golangci-lint-action@v9 17 | with: 18 | # Optional: golangci-lint command line arguments. 19 | args: --issues-exit-code=0 20 | # Optional: show only new issues if it's a pull request. The default value is `false`. 21 | only-new-issues: true 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/goreleaser.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json 2 | 3 | name: goreleaser 4 | 5 | on: 6 | push: 7 | tags: 8 | - v*.*.* 9 | 10 | concurrency: 11 | group: goreleaser 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | goreleaser: 16 | uses: charmbracelet/meta/.github/workflows/goreleaser.yml@main 17 | secrets: 18 | docker_username: ${{ secrets.DOCKERHUB_USERNAME }} 19 | docker_token: ${{ secrets.DOCKERHUB_TOKEN }} 20 | gh_pat: ${{ secrets.PERSONAL_ACCESS_TOKEN }} 21 | goreleaser_key: ${{ secrets.GORELEASER_KEY }} 22 | fury_token: ${{ secrets.FURY_TOKEN }} 23 | nfpm_gpg_key: ${{ secrets.NFPM_GPG_KEY }} 24 | nfpm_passphrase: ${{ secrets.NFPM_PASSPHRASE }} 25 | aur_key: ${{ secrets.AUR_KEY }} 26 | -------------------------------------------------------------------------------- /examples/demo.tape: -------------------------------------------------------------------------------- 1 | Output demo.gif 2 | Set Framerate 28 3 | Set FontSize 33 4 | Set Padding 50 5 | Set Width 1000 6 | Set Height 1000 7 | 8 | Hide 9 | Type "export RESEND_API_KEY=$(pass RESEND_CHARM_API_KEY)" 10 | Enter 11 | Sleep 1 12 | Ctrl+L 13 | Show 14 | 15 | Type "pop" 16 | Enter 17 | Sleep 2 18 | 19 | Type "pop@charm.sh" 20 | Sleep 0.5 21 | Tab 22 | Sleep 1 23 | 24 | Type "vt100@charm.sh" 25 | Sleep 0.5 26 | Tab 27 | Sleep 1 28 | 29 | Type "Pop" 30 | Sleep 0.5 31 | Tab 32 | Sleep 1 33 | 34 | Type "# Welcome to Pop." 35 | Sleep 0.5 36 | Enter 2 37 | Sleep 0.5 38 | Type "Send *emails* from the terminal." 39 | Sleep 0.5 40 | Enter 2 41 | Sleep 0.5 42 | Type "Sincerely," 43 | Sleep 0.5 44 | Enter 45 | Sleep 0.5 46 | Type "Charm_" 47 | Sleep 2 48 | Tab 49 | Sleep 1 50 | Enter 51 | Sleep 1 52 | Escape 53 | Sleep 1 54 | Tab 55 | Sleep 1 56 | Enter 57 | Sleep 5 58 | -------------------------------------------------------------------------------- /.golangci-soft.yml: -------------------------------------------------------------------------------- 1 | run: 2 | tests: false 3 | 4 | issues: 5 | include: 6 | - EXC0001 7 | - EXC0005 8 | - EXC0011 9 | - EXC0012 10 | - EXC0013 11 | 12 | max-issues-per-linter: 0 13 | max-same-issues: 0 14 | 15 | linters: 16 | enable: 17 | # - dupl 18 | - exhaustive 19 | # - exhaustivestruct 20 | - goconst 21 | - godot 22 | - godox 23 | - gomnd 24 | - gomoddirectives 25 | - goprintffuncname 26 | - ifshort 27 | # - lll 28 | - misspell 29 | - nakedret 30 | - nestif 31 | - noctx 32 | - nolintlint 33 | - prealloc 34 | - wrapcheck 35 | 36 | # disable default linters, they are already enabled in .golangci.yml 37 | disable: 38 | - deadcode 39 | - errcheck 40 | - gosimple 41 | - govet 42 | - ineffassign 43 | - staticcheck 44 | - structcheck 45 | - typecheck 46 | - unused 47 | - varcheck 48 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /attachments.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io" 5 | "path/filepath" 6 | 7 | "github.com/charmbracelet/bubbles/list" 8 | tea "github.com/charmbracelet/bubbletea" 9 | ) 10 | 11 | type attachment string 12 | 13 | func (a attachment) FilterValue() string { 14 | return string(a) 15 | } 16 | 17 | type attachmentDelegate struct { 18 | focused bool 19 | } 20 | 21 | func (d attachmentDelegate) Height() int { 22 | return 1 23 | } 24 | 25 | func (d attachmentDelegate) Spacing() int { 26 | return 0 27 | } 28 | 29 | func (d attachmentDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) { 30 | path := filepath.Base(item.(attachment).FilterValue()) 31 | style := textStyle 32 | if m.Index() == index && d.focused { 33 | style = activeTextStyle 34 | } 35 | 36 | if m.Index() == index { 37 | _, _ = w.Write([]byte(style.Render("• " + path))) 38 | } else { 39 | _, _ = w.Write([]byte(style.Render(" " + path))) 40 | } 41 | } 42 | 43 | func (d attachmentDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { 44 | return nil 45 | } 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Charmbracelet, Inc 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: "gomod" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | day: "monday" 9 | time: "05:00" 10 | timezone: "America/New_York" 11 | labels: 12 | - "dependencies" 13 | commit-message: 14 | prefix: "chore" 15 | include: "scope" 16 | groups: 17 | all: 18 | patterns: 19 | - "*" 20 | 21 | - package-ecosystem: "github-actions" 22 | directory: "/" 23 | schedule: 24 | interval: "weekly" 25 | day: "monday" 26 | time: "05:00" 27 | timezone: "America/New_York" 28 | labels: 29 | - "dependencies" 30 | commit-message: 31 | prefix: "chore" 32 | include: "scope" 33 | groups: 34 | all: 35 | patterns: 36 | - "*" 37 | 38 | - package-ecosystem: "docker" 39 | directory: "/" 40 | schedule: 41 | interval: "weekly" 42 | day: "monday" 43 | time: "05:00" 44 | timezone: "America/New_York" 45 | labels: 46 | - "dependencies" 47 | commit-message: 48 | prefix: "chore" 49 | include: "scope" 50 | groups: 51 | all: 52 | patterns: 53 | - "*" 54 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/charmbracelet/pop 2 | 3 | go 1.24.0 4 | 5 | toolchain go1.24.1 6 | 7 | require ( 8 | github.com/charmbracelet/bubbles v0.21.0 9 | github.com/charmbracelet/bubbletea v1.3.10 10 | github.com/charmbracelet/lipgloss v1.1.0 11 | github.com/charmbracelet/x/exp/ordered v0.1.0 12 | github.com/muesli/mango-cobra v1.3.0 13 | github.com/muesli/roff v0.1.0 14 | github.com/resendlabs/resend-go v1.7.0 15 | github.com/spf13/cobra v1.10.2 16 | github.com/xhit/go-simple-mail/v2 v2.16.0 17 | github.com/yuin/goldmark v1.7.13 18 | ) 19 | 20 | require ( 21 | github.com/atotto/clipboard v0.1.4 // indirect 22 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 23 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 24 | github.com/charmbracelet/x/ansi v0.10.1 // indirect 25 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect 26 | github.com/charmbracelet/x/term v0.2.1 // indirect 27 | github.com/dustin/go-humanize v1.0.1 // indirect 28 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 29 | github.com/go-test/deep v1.1.0 // indirect 30 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 31 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 32 | github.com/mattn/go-isatty v0.0.20 // indirect 33 | github.com/mattn/go-localereader v0.0.1 // indirect 34 | github.com/mattn/go-runewidth v0.0.16 // indirect 35 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 36 | github.com/muesli/cancelreader v0.2.2 // indirect 37 | github.com/muesli/mango v0.2.0 // indirect 38 | github.com/muesli/mango-pflag v0.1.0 // indirect 39 | github.com/muesli/termenv v0.16.0 // indirect 40 | github.com/rivo/uniseg v0.4.7 // indirect 41 | github.com/sahilm/fuzzy v0.1.1 // indirect 42 | github.com/spf13/pflag v1.0.9 // indirect 43 | github.com/toorop/go-dkim v0.0.0-20240103092955-90b7d1423f92 // indirect 44 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 45 | golang.org/x/sys v0.36.0 // indirect 46 | golang.org/x/text v0.16.0 // indirect 47 | ) 48 | -------------------------------------------------------------------------------- /style.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/charmbracelet/lipgloss" 7 | ) 8 | 9 | const accentColor = lipgloss.Color("99") 10 | const yellowColor = lipgloss.Color("#ECFD66") 11 | const whiteColor = lipgloss.Color("255") 12 | const grayColor = lipgloss.Color("241") 13 | const darkGrayColor = lipgloss.Color("236") 14 | const lightGrayColor = lipgloss.Color("247") 15 | 16 | var ( 17 | activeTextStyle = lipgloss.NewStyle().Foreground(whiteColor) 18 | textStyle = lipgloss.NewStyle().Foreground(lightGrayColor) 19 | 20 | activeLabelStyle = lipgloss.NewStyle().Foreground(accentColor) 21 | labelStyle = lipgloss.NewStyle().Foreground(grayColor) 22 | 23 | placeholderStyle = lipgloss.NewStyle().Foreground(darkGrayColor) 24 | cursorStyle = lipgloss.NewStyle().Foreground(whiteColor) 25 | 26 | paddedStyle = lipgloss.NewStyle().Padding(1) 27 | 28 | errorHeaderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#F1F1F1")).Background(lipgloss.Color("#FF5F87")).Bold(true).Padding(0, 1).SetString("ERROR") 29 | errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5F87")) 30 | commentStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#757575")).PaddingLeft(1) 31 | 32 | sendButtonActiveStyle = lipgloss.NewStyle().Background(accentColor).Foreground(yellowColor).Padding(0, 2) 33 | sendButtonInactiveStyle = lipgloss.NewStyle().Background(darkGrayColor).Foreground(lightGrayColor).Padding(0, 2) 34 | sendButtonStyle = lipgloss.NewStyle().Background(darkGrayColor).Foreground(grayColor).Padding(0, 2) 35 | 36 | inlineCodeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5F87")).Background(lipgloss.Color("#3A3A3A")).Padding(0, 1) 37 | linkStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#00AF87")).Underline(true) 38 | ) 39 | 40 | // emailSummary returns a summary of the email that was sent. It is used when 41 | // the user has sent an email successfully. 42 | func emailSummary(to []string, subject string) string { 43 | var s strings.Builder 44 | s.WriteString("\n Email ") 45 | s.WriteString(activeTextStyle.Render("\"" + subject + "\"")) 46 | s.WriteString(" sent to ") 47 | for i, t := range to { 48 | if i > 0 { 49 | s.WriteString(", ") 50 | } 51 | s.WriteString(linkStyle.Render(t)) 52 | } 53 | s.WriteString("\n\n") 54 | 55 | return s.String() 56 | } 57 | -------------------------------------------------------------------------------- /keymap.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/charmbracelet/bubbles/key" 4 | 5 | // KeyMap represents the key bindings for the application. 6 | type KeyMap struct { 7 | NextInput key.Binding 8 | PrevInput key.Binding 9 | Send key.Binding 10 | Attach key.Binding 11 | Unattach key.Binding 12 | Back key.Binding 13 | Quit key.Binding 14 | } 15 | 16 | // DefaultKeybinds returns the default key bindings for the application. 17 | func DefaultKeybinds() KeyMap { 18 | return KeyMap{ 19 | NextInput: key.NewBinding( 20 | key.WithKeys("tab"), 21 | key.WithHelp("tab", "next"), 22 | ), 23 | PrevInput: key.NewBinding( 24 | key.WithKeys("shift+tab"), 25 | ), 26 | Send: key.NewBinding( 27 | key.WithKeys("ctrl+d", "enter"), 28 | key.WithHelp("enter", "send"), 29 | key.WithDisabled(), 30 | ), 31 | Attach: key.NewBinding( 32 | key.WithKeys("enter"), 33 | key.WithHelp("enter", "attach file"), 34 | key.WithDisabled(), 35 | ), 36 | Unattach: key.NewBinding( 37 | key.WithKeys("x"), 38 | key.WithHelp("x", "remove"), 39 | key.WithDisabled(), 40 | ), 41 | Back: key.NewBinding( 42 | key.WithKeys("esc"), 43 | key.WithHelp("esc", "back"), 44 | key.WithDisabled(), 45 | ), 46 | Quit: key.NewBinding( 47 | key.WithKeys("ctrl+c"), 48 | key.WithHelp("ctrl+c", "quit"), 49 | ), 50 | } 51 | } 52 | 53 | // ShortHelp returns the key bindings for the short help screen. 54 | func (k KeyMap) ShortHelp() []key.Binding { 55 | return []key.Binding{ 56 | k.NextInput, 57 | k.Quit, 58 | k.Attach, 59 | k.Unattach, 60 | k.Send, 61 | } 62 | } 63 | 64 | // FullHelp returns the key bindings for the full help screen. 65 | func (k KeyMap) FullHelp() [][]key.Binding { 66 | return [][]key.Binding{ 67 | {k.NextInput, k.Send, k.Attach, k.Unattach, k.Quit}, 68 | } 69 | } 70 | 71 | func (m *Model) updateKeymap() { 72 | m.keymap.Attach.SetEnabled(m.state == editingAttachments) 73 | m.keymap.Send.SetEnabled(m.canSend() && m.state == hoveringSendButton) 74 | m.keymap.Unattach.SetEnabled(m.state == editingAttachments && len(m.Attachments.Items()) > 0) 75 | m.keymap.Back.SetEnabled(m.state == pickingFile) 76 | 77 | m.filepicker.KeyMap.Up.SetEnabled(m.state == pickingFile) 78 | m.filepicker.KeyMap.Down.SetEnabled(m.state == pickingFile) 79 | m.filepicker.KeyMap.Back.SetEnabled(m.state == pickingFile) 80 | m.filepicker.KeyMap.Select.SetEnabled(m.state == pickingFile) 81 | m.filepicker.KeyMap.Open.SetEnabled(m.state == pickingFile) 82 | m.filepicker.KeyMap.PageUp.SetEnabled(m.state == pickingFile) 83 | m.filepicker.KeyMap.PageDown.SetEnabled(m.state == pickingFile) 84 | m.filepicker.KeyMap.GoToTop.SetEnabled(m.state == pickingFile) 85 | m.filepicker.KeyMap.GoToLast.SetEnabled(m.state == pickingFile) 86 | } 87 | 88 | func (m Model) canSend() bool { 89 | return m.From.Value() != "" && m.To.Value() != "" && m.Subject.Value() != "" && m.Body.Value() != "" 90 | } 91 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pop 2 | 3 |

4 | 5 |
6 | Latest Release 7 | Go Docs 8 | Build Status 9 |

10 | 11 | Send emails from your terminal. 12 | 13 | pop mail text-based client 14 | 15 | ## Text-based User Interface 16 | 17 | Launch the TUI 18 | 19 | ```bash 20 | pop 21 | ``` 22 | 23 | ## Command Line Interface 24 | 25 | ```bash 26 | pop < message.md \ 27 | --from "me@example.com" \ 28 | --to "you@example.com" \ 29 | --subject "Hello, world!" \ 30 | --attach invoice.pdf 31 | ``` 32 | 33 | pop mail command line client 34 | 35 | --- 36 | 37 | Resend and Charm logos 38 | 39 | To use `pop`, you will need a `RESEND_API_KEY` or configure an 40 | [`SMTP`](#smtp-configuration) host. 41 | 42 | You can grab one from: https://resend.com/api-keys. 43 | 44 | ### Resend Configuration 45 | 46 | To use the resend delivery method, set the `RESEND_API_KEY` environment 47 | variable. 48 | 49 | ```bash 50 | export RESEND_API_KEY=$(pass RESEND_API_KEY) 51 | ``` 52 | 53 | 54 | ### SMTP Configuration 55 | 56 | To configure `pop` to use `SMTP`, you can set the following environment 57 | variables. 58 | 59 | ```bash 60 | export POP_SMTP_HOST=smtp.gmail.com 61 | export POP_SMTP_PORT=587 62 | export POP_SMTP_USERNAME=pop@charm.sh 63 | export POP_SMTP_PASSWORD=hunter2 64 | ``` 65 | 66 | ### Environment 67 | 68 | To avoid typing your `From: ` email address, you can also set the `POP_FROM` 69 | environment to pre-fill the field anytime you launch `pop`. 70 | 71 | ```bash 72 | export POP_FROM=pop@charm.sh 73 | export POP_SIGNATURE="Sent with [Pop](https://github.com/charmbracelet/pop)!" 74 | ``` 75 | > **Note**: 76 | > If you wish to use a resend account without a custom domain, you can use 77 | > `onboarding@resend.dev` to send emails. 78 | 79 | ## Installation 80 | 81 | Use a package manager: 82 | 83 | ```bash 84 | # macOS or Linux 85 | brew install pop 86 | 87 | # Nix 88 | nix-env -iA nixpkgs.pop 89 | 90 | # Arch (btw) 91 | yay -S charm-pop-bin 92 | ``` 93 | 94 | Install with Go: 95 | 96 | ```sh 97 | go install github.com/charmbracelet/pop@latest 98 | ``` 99 | 100 | Or download a binary from the [releases](https://github.com/charmbracelet/pop/releases). 101 | 102 | ## Examples 103 | 104 | Pop can be combined with other tools to create powerful email pipelines, such as: 105 | 106 | - [`charmbracelet/mods`](https://github.com/charmbracelet/mods) 107 | - [`charmbracelet/gum`](https://github.com/charmbracelet/gum) 108 | - [`maaslalani/invoice`](https://github.com/maaslalani/invoice) 109 | 110 | ### Mods 111 | 112 | Use [`mods`](https://github.com/charmbracelet/mods) with `pop` to write an email body with AI: 113 | 114 | > **Note**: 115 | > Use the `--preview` flag to preview the email and make changes before sending. 116 | 117 | ```bash 118 | pop <<< "$(mods -f 'Explain why CLIs are awesome')" \ 119 | --subject "The command line is the best" \ 120 | --preview 121 | ``` 122 | 123 | Generate email with mods and send email with pop. 124 | 125 | - [`charmbracelet/mods`](https://github.com/charmbracelet/mods) 126 | 127 | ### Gum 128 | 129 | Use [`gum`](https://github.com/charmbracelet/gum) with `pop` to choose an email to send to and from: 130 | 131 | ```bash 132 | pop --from $(gum choose "vt52@charm.sh" "vt78@charm.sh" "vt100@charm.sh") 133 | --to $(gum filter < contacts.txt) 134 | ``` 135 | 136 | Select contact information with gum and send email with pop. 137 | 138 | - [`charmbracelet/gum`](https://github.com/charmbracelet/gum) 139 | 140 | ### Invoice 141 | 142 | Use [`invoice`](https://github.com/maaslalani/invoice) with `pop` to generate and send invoices entirely from the command line. 143 | 144 | ```bash 145 | FILENAME=invoice.pdf 146 | invoice generate --item "Rubber Ducky" --rate 25 --quantity 2 --output $FILENAME 147 | pop --attach $FILENAME --body "See attached invoice." 148 | ``` 149 | 150 | Generate invoice with invoice and attach file and send email with pop. 151 | 152 | - [`maaslalani/invoice`](https://github.com/maaslalani/invoice) 153 | 154 | ## Feedback 155 | 156 | We’d love to hear your thoughts on this project. Feel free to drop us a note! 157 | 158 | - [Twitter](https://twitter.com/charmcli) 159 | - [The Fediverse](https://mastodon.social/@charmcli) 160 | - [Discord](https://charm.sh/chat) 161 | 162 | ## License 163 | 164 | [MIT](https://github.com/charmbracelet/pop/blob/main/LICENSE) 165 | 166 | --- 167 | 168 | Part of [Charm](https://charm.sh). 169 | 170 | 171 | The Charm logo 176 | 177 | 178 | Charm 热爱开源 • Charm loves open source 179 | -------------------------------------------------------------------------------- /email.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "crypto/tls" 6 | "errors" 7 | "fmt" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | "time" 12 | 13 | tea "github.com/charmbracelet/bubbletea" 14 | "github.com/resendlabs/resend-go" 15 | mail "github.com/xhit/go-simple-mail/v2" 16 | "github.com/yuin/goldmark" 17 | "github.com/yuin/goldmark/extension" 18 | renderer "github.com/yuin/goldmark/renderer/html" 19 | ) 20 | 21 | // ToSeparator is the separator used to split the To, Cc, and Bcc fields. 22 | const ToSeparator = "," 23 | 24 | // sendEmailSuccessMsg is the tea.Msg handled by Bubble Tea when the email has 25 | // been sent successfully. 26 | type sendEmailSuccessMsg struct{} 27 | 28 | // sendEmailFailureMsg is the tea.Msg handled by Bubble Tea when the email has 29 | // failed to send. 30 | type sendEmailFailureMsg error 31 | 32 | // sendEmailCmd returns a tea.Cmd that sends the email. 33 | func (m Model) sendEmailCmd() tea.Cmd { 34 | return func() tea.Msg { 35 | attachments := make([]string, len(m.Attachments.Items())) 36 | for i, a := range m.Attachments.Items() { 37 | attachments[i] = a.FilterValue() 38 | } 39 | var err error 40 | to := strings.Split(m.To.Value(), ToSeparator) 41 | cc := strings.Split(m.Cc.Value(), ToSeparator) 42 | bcc := strings.Split(m.Bcc.Value(), ToSeparator) 43 | switch m.DeliveryMethod { 44 | case SMTP: 45 | err = sendSMTPEmail(to, cc, bcc, m.From.Value(), m.Subject.Value(), m.Body.Value(), attachments) 46 | case Resend: 47 | err = sendResendEmail(to, cc, bcc, m.From.Value(), m.Subject.Value(), m.Body.Value(), attachments) 48 | default: 49 | err = errors.New("[ERROR]: unknown delivery method") 50 | } 51 | if err != nil { 52 | path, storeErr := saveTmp(m.Body.Value()) 53 | if storeErr == nil { 54 | err = fmt.Errorf("%w\nEmail saved to: %s", err, path) 55 | } 56 | return sendEmailFailureMsg(err) 57 | } 58 | return sendEmailSuccessMsg{} 59 | } 60 | } 61 | 62 | const gmailSuffix = "@gmail.com" 63 | const gmailSMTPHost = "smtp.gmail.com" 64 | const gmailSMTPPort = 587 65 | 66 | func sendSMTPEmail(to, cc, bcc []string, from, subject, body string, attachments []string) error { 67 | server := mail.NewSMTPClient() 68 | 69 | var err error 70 | server.Username = smtpUsername 71 | server.Password = smtpPassword 72 | server.Host = smtpHost 73 | server.Port = smtpPort 74 | 75 | // Set defaults for gmail. 76 | if strings.HasSuffix(server.Username, gmailSuffix) { 77 | if server.Port == 0 { 78 | server.Port = gmailSMTPPort 79 | } 80 | if server.Host == "" { 81 | server.Host = gmailSMTPHost 82 | } 83 | } 84 | 85 | switch strings.ToLower(smtpEncryption) { 86 | case "ssl": 87 | server.Encryption = mail.EncryptionSSLTLS 88 | case "none": 89 | server.Encryption = mail.EncryptionNone 90 | default: 91 | server.Encryption = mail.EncryptionSTARTTLS 92 | } 93 | 94 | server.KeepAlive = false 95 | server.ConnectTimeout = 10 * time.Second 96 | server.SendTimeout = 10 * time.Second 97 | server.TLSConfig = &tls.Config{ 98 | InsecureSkipVerify: smtpInsecureSkipVerify, //nolint:gosec 99 | ServerName: server.Host, 100 | } 101 | 102 | smtpClient, err := server.Connect() 103 | 104 | if err != nil { 105 | return err 106 | } 107 | 108 | email := mail.NewMSG() 109 | email.SetFrom(from). 110 | AddTo(to...). 111 | AddCc(cc...). 112 | AddBcc(bcc...). 113 | SetSubject(subject) 114 | 115 | html := bytes.NewBufferString("") 116 | convertErr := goldmark.Convert([]byte(body), html) 117 | 118 | if convertErr != nil { 119 | email.SetBody(mail.TextPlain, body) 120 | } else { 121 | email.SetBody(mail.TextHTML, html.String()) 122 | } 123 | 124 | for _, a := range attachments { 125 | email.Attach(&mail.File{ 126 | FilePath: a, 127 | Name: filepath.Base(a), 128 | }) 129 | } 130 | 131 | return email.Send(smtpClient) 132 | } 133 | 134 | func sendResendEmail(to, _, _ []string, from, subject, body string, attachments []string) error { 135 | client := resend.NewClient(resendAPIKey) 136 | 137 | html := bytes.NewBufferString("") 138 | // If the conversion fails, we'll simply send the plain-text body. 139 | if unsafe { 140 | markdown := goldmark.New( 141 | goldmark.WithRendererOptions( 142 | renderer.WithUnsafe(), 143 | ), 144 | goldmark.WithExtensions( 145 | extension.Strikethrough, 146 | extension.Table, 147 | extension.Linkify, 148 | ), 149 | ) 150 | _ = markdown.Convert([]byte(body), html) 151 | } else { 152 | _ = goldmark.Convert([]byte(body), html) 153 | } 154 | 155 | request := &resend.SendEmailRequest{ 156 | From: from, 157 | To: to, 158 | Subject: subject, 159 | Cc: cc, 160 | Bcc: bcc, 161 | Html: html.String(), 162 | Text: body, 163 | Attachments: makeAttachments(attachments), 164 | } 165 | 166 | _, err := client.Emails.Send(request) 167 | if err != nil { 168 | return err 169 | } 170 | 171 | return nil 172 | } 173 | 174 | func makeAttachments(paths []string) []resend.Attachment { 175 | if len(paths) == 0 { 176 | return nil 177 | } 178 | 179 | attachments := make([]resend.Attachment, len(paths)) 180 | for i, a := range paths { 181 | f, err := os.ReadFile(a) 182 | if err != nil { 183 | continue 184 | } 185 | attachments[i] = resend.Attachment{ 186 | Content: string(f), 187 | Filename: filepath.Base(a), 188 | } 189 | } 190 | 191 | return attachments 192 | } 193 | 194 | // saveTmp is a helper function that stores a string in a temporary file. 195 | // It returns the path of the file created. 196 | func saveTmp(s string) (string, error) { 197 | f, err := os.CreateTemp("", fmt.Sprintf("pop-%s-*.txt", time.Now().Format("2006-01-02"))) 198 | if err != nil { 199 | return "", fmt.Errorf("creating temp file: %w", err) 200 | } 201 | defer f.Close() 202 | 203 | _, err = f.WriteString(s) 204 | if err != nil { 205 | return "", fmt.Errorf("error writing to %s: %w", f.Name(), err) 206 | } 207 | 208 | return f.Name(), nil 209 | } 210 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= 2 | github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= 3 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 4 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 5 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 6 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 7 | github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= 8 | github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= 9 | github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= 10 | github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= 11 | github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= 12 | github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= 13 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= 14 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 15 | github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 16 | github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 17 | github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= 18 | github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= 19 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= 20 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 21 | github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= 22 | github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= 23 | github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= 24 | github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8= 25 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 26 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 27 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 28 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 29 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 30 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 31 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 32 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 33 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 34 | github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg= 35 | github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= 36 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 37 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 38 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 39 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 40 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 41 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 42 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 43 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 44 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 45 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 46 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 47 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 48 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 49 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 50 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 51 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 52 | github.com/muesli/mango v0.2.0 h1:iNNc0c5VLQ6fsMgAqGQofByNUBH2Q2nEbD6TaI+5yyQ= 53 | github.com/muesli/mango v0.2.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4= 54 | github.com/muesli/mango-cobra v1.3.0 h1:vQy5GvPg3ndOSpduxutqFoINhWk3vD5K2dXo5E8pqec= 55 | github.com/muesli/mango-cobra v1.3.0/go.mod h1:Cj1ZrBu3806Qw7UjxnAUgE+7tllUBj1NCLQDwwGx19E= 56 | github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg= 57 | github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0= 58 | github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= 59 | github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= 60 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 61 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 62 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 63 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 64 | github.com/resendlabs/resend-go v1.7.0 h1:DycOqSXtw2q7aB+Nt9DDJUDtaYcrNPGn1t5RFposas0= 65 | github.com/resendlabs/resend-go v1.7.0/go.mod h1:yip1STH7Bqfm4fD0So5HgyNbt5taG5Cplc4xXxETyLI= 66 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 67 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 68 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 69 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 70 | github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= 71 | github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 72 | github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= 73 | github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= 74 | github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= 75 | github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 76 | github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= 77 | github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 78 | github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns= 79 | github.com/toorop/go-dkim v0.0.0-20240103092955-90b7d1423f92 h1:flbMkdl6HxQkLs6DDhH1UkcnFpNBOu70391STjMS0O4= 80 | github.com/toorop/go-dkim v0.0.0-20240103092955-90b7d1423f92/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns= 81 | github.com/xhit/go-simple-mail/v2 v2.16.0 h1:ouGy/Ww4kuaqu2E2UrDw7SvLaziWTB60ICLkIkNVccA= 82 | github.com/xhit/go-simple-mail/v2 v2.16.0/go.mod h1:b7P5ygho6SYE+VIqpxA6QkYfv4teeyG4MKqB3utRu98= 83 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 84 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 85 | github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= 86 | github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 87 | go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 88 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= 89 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= 90 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 91 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 92 | golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= 93 | golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 94 | golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= 95 | golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= 96 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 97 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 98 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 99 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "os" 8 | "runtime/debug" 9 | "strconv" 10 | "strings" 11 | 12 | tea "github.com/charmbracelet/bubbletea" 13 | mcobra "github.com/muesli/mango-cobra" 14 | "github.com/muesli/roff" 15 | "github.com/resendlabs/resend-go" 16 | "github.com/spf13/cobra" 17 | ) 18 | 19 | // PopUnsafeHTML is the environment variable that enables unsafe HTML in the 20 | // email body. 21 | const PopUnsafeHTML = "POP_UNSAFE_HTML" 22 | 23 | // ResendAPIKey is the environment variable that enables Resend as a delivery 24 | // method and uses it to send the email. 25 | const ResendAPIKey = "RESEND_API_KEY" //nolint:gosec 26 | 27 | // PopFrom is the environment variable that sets the default "from" address. 28 | const PopFrom = "POP_FROM" 29 | 30 | // PopSignature is the environment variable that sets the default signature. 31 | const PopSignature = "POP_SIGNATURE" 32 | 33 | // PopSMTPHost is the host for the SMTP server if the user is using the SMTP delivery method. 34 | const PopSMTPHost = "POP_SMTP_HOST" 35 | 36 | // PopSMTPPort is the port for the SMTP server if the user is using the SMTP delivery method. 37 | const PopSMTPPort = "POP_SMTP_PORT" 38 | 39 | // PopSMTPUsername is the username for the SMTP server if the user is using the SMTP delivery method. 40 | const PopSMTPUsername = "POP_SMTP_USERNAME" 41 | 42 | // PopSMTPPassword is the password for the SMTP server if the user is using the SMTP delivery method. 43 | const PopSMTPPassword = "POP_SMTP_PASSWORD" //nolint:gosec 44 | 45 | // PopSMTPEncryption is the encryption type for the SMTP server if the user is using the SMTP delivery method. 46 | const PopSMTPEncryption = "POP_SMTP_ENCRYPTION" //nolint:gosec 47 | 48 | // PopSMTPInsecureSkipVerify is whether or not to skip TLS verification for the 49 | // SMTP server if the user is using the SMTP delivery method. 50 | const PopSMTPInsecureSkipVerify = "POP_SMTP_INSECURE_SKIP_VERIFY" 51 | 52 | var ( 53 | from string 54 | to []string 55 | cc []string 56 | bcc []string 57 | subject string 58 | body string 59 | attachments []string 60 | preview bool 61 | unsafe bool 62 | signature string 63 | smtpHost string 64 | smtpPort int 65 | smtpUsername string 66 | smtpPassword string 67 | smtpEncryption string 68 | smtpInsecureSkipVerify bool 69 | resendAPIKey string 70 | ) 71 | 72 | var rootCmd = &cobra.Command{ 73 | Use: "pop", 74 | Short: "Send emails from your terminal", 75 | Long: `Pop is a tool for sending emails from your terminal.`, 76 | RunE: func(cmd *cobra.Command, args []string) error { 77 | var deliveryMethod DeliveryMethod 78 | switch { 79 | case resendAPIKey != "" && smtpUsername != "" && smtpPassword != "": 80 | deliveryMethod = Unknown 81 | case resendAPIKey != "": 82 | deliveryMethod = Resend 83 | case smtpUsername != "" && smtpPassword != "": 84 | deliveryMethod = SMTP 85 | if from == "" { 86 | from = smtpUsername 87 | } 88 | } 89 | 90 | switch deliveryMethod { 91 | case None: 92 | fmt.Printf("\n %s %s %s\n\n", errorHeaderStyle.String(), inlineCodeStyle.Render(ResendAPIKey), "environment variable is required.") 93 | fmt.Printf(" %s %s\n\n", commentStyle.Render("You can grab one at"), linkStyle.Render("https://resend.com/api-keys")) 94 | cmd.SilenceUsage = true 95 | cmd.SilenceErrors = true 96 | return errors.New("missing required environment variable") 97 | case Unknown: 98 | fmt.Printf("\n %s Unknown delivery method.\n", errorHeaderStyle.String()) 99 | fmt.Printf("\n You have set both %s and %s delivery methods.", inlineCodeStyle.Render(ResendAPIKey), inlineCodeStyle.Render("POP_SMPT_*")) 100 | fmt.Printf("\n Set only one of these environment variables.\n\n") 101 | cmd.SilenceUsage = true 102 | cmd.SilenceErrors = true 103 | return errors.New("unknown delivery method") 104 | } 105 | 106 | if body == "" && hasStdin() { 107 | b, err := io.ReadAll(os.Stdin) 108 | if err != nil { 109 | return err 110 | } 111 | body = string(b) 112 | } 113 | 114 | if signature != "" { 115 | body += "\n\n" + signature 116 | } 117 | 118 | if len(to) > 0 && from != "" && subject != "" && body != "" && !preview { 119 | var err error 120 | switch deliveryMethod { 121 | case SMTP: 122 | err = sendSMTPEmail(to, cc, bcc, from, subject, body, attachments) 123 | case Resend: 124 | err = sendResendEmail(to, cc, bcc, from, subject, body, attachments) 125 | default: 126 | err = fmt.Errorf("unknown delivery method") 127 | } 128 | if err != nil { 129 | cmd.SilenceUsage = true 130 | cmd.SilenceErrors = true 131 | fmt.Println(errorStyle.Render(err.Error())) 132 | return err 133 | } 134 | fmt.Print(emailSummary(to, subject)) 135 | return nil 136 | } 137 | 138 | p := tea.NewProgram(NewModel(resend.SendEmailRequest{ 139 | From: from, 140 | To: to, 141 | Bcc: bcc, 142 | Cc: cc, 143 | Subject: subject, 144 | Text: body, 145 | Attachments: makeAttachments(attachments), 146 | }, deliveryMethod)) 147 | 148 | m, err := p.Run() 149 | if err != nil { 150 | return err 151 | } 152 | mm := m.(Model) 153 | if !mm.abort { 154 | fmt.Print(emailSummary(strings.Split(mm.To.Value(), ToSeparator), mm.Subject.Value())) 155 | } 156 | return nil 157 | }, 158 | } 159 | 160 | // hasStdin returns whether there is data in stdin. 161 | func hasStdin() bool { 162 | stat, err := os.Stdin.Stat() 163 | return err == nil && (stat.Mode()&os.ModeCharDevice) == 0 164 | } 165 | 166 | var ( 167 | // Version stores the build version of VHS at the time of package through 168 | // -ldflags. 169 | // 170 | // go build -ldflags "-s -w -X=main.Version=$(VERSION)" 171 | Version string 172 | 173 | // CommitSHA stores the git commit SHA at the time of package through -ldflags. 174 | CommitSHA string 175 | ) 176 | 177 | // ManCmd is the cobra command for the manual. 178 | var ManCmd = &cobra.Command{ 179 | Use: "man", 180 | Short: "Generate man page", 181 | Long: `To generate the man page`, 182 | Args: cobra.NoArgs, 183 | Hidden: true, 184 | RunE: func(_ *cobra.Command, _ []string) error { 185 | page, err := mcobra.NewManPage(1, rootCmd) // . 186 | if err != nil { 187 | return err 188 | } 189 | 190 | page = page.WithSection("Copyright", "© 2023 Charmbracelet, Inc.\n"+"Released under MIT License.") 191 | fmt.Println(page.Build(roff.NewDocument())) 192 | return nil 193 | }, 194 | } 195 | 196 | func init() { 197 | rootCmd.AddCommand(ManCmd) 198 | 199 | rootCmd.Flags().StringSliceVar(&bcc, "bcc", []string{}, "BCC recipients") 200 | rootCmd.Flags().StringSliceVar(&cc, "cc", []string{}, "CC recipients") 201 | rootCmd.Flags().StringSliceVarP(&attachments, "attach", "a", []string{}, "Email's attachments") 202 | rootCmd.Flags().StringSliceVarP(&to, "to", "t", []string{}, "Recipients") 203 | rootCmd.Flags().StringVarP(&body, "body", "b", "", "Email's contents") 204 | envFrom := os.Getenv(PopFrom) 205 | rootCmd.Flags().StringVarP(&from, "from", "f", envFrom, "Email's sender"+commentStyle.Render("($"+PopFrom+")")) 206 | rootCmd.Flags().StringVarP(&subject, "subject", "s", "", "Email's subject") 207 | rootCmd.Flags().BoolVar(&preview, "preview", false, "Whether to preview the email before sending") 208 | envUnsafe := os.Getenv(PopUnsafeHTML) == "true" 209 | rootCmd.Flags().BoolVarP(&unsafe, "unsafe", "u", envUnsafe, "Whether to allow unsafe HTML in the email body, also enable some extra markdown features (Experimental)") 210 | envSignature := os.Getenv(PopSignature) 211 | rootCmd.Flags().StringVarP(&signature, "signature", "x", envSignature, "Signature to display at the end of the email."+commentStyle.Render("($"+PopSignature+")")) 212 | envSMTPHost := os.Getenv(PopSMTPHost) 213 | rootCmd.Flags().StringVarP(&smtpHost, "smtp.host", "H", envSMTPHost, "Host of the SMTP server"+commentStyle.Render("($"+PopSMTPHost+")")) 214 | envSMTPPort, _ := strconv.Atoi(os.Getenv(PopSMTPPort)) 215 | if envSMTPPort == 0 { 216 | envSMTPPort = 587 217 | } 218 | rootCmd.Flags().IntVarP(&smtpPort, "smtp.port", "P", envSMTPPort, "Port of the SMTP server"+commentStyle.Render("($"+PopSMTPPort+")")) 219 | envSMTPUsername := os.Getenv(PopSMTPUsername) 220 | rootCmd.Flags().StringVarP(&smtpUsername, "smtp.username", "U", envSMTPUsername, "Username of the SMTP server"+commentStyle.Render("($"+PopSMTPUsername+")")) 221 | envSMTPPassword := os.Getenv(PopSMTPPassword) 222 | rootCmd.Flags().StringVarP(&smtpPassword, "smtp.password", "p", envSMTPPassword, "Password of the SMTP server"+commentStyle.Render("($"+PopSMTPPassword+")")) 223 | envSMTPEncryption := os.Getenv(PopSMTPEncryption) 224 | rootCmd.Flags().StringVarP(&smtpEncryption, "smtp.encryption", "e", envSMTPEncryption, "Encryption type of the SMTP server (starttls, ssl, or none)"+commentStyle.Render("($"+PopSMTPEncryption+")")) 225 | envInsecureSkipVerify := os.Getenv(PopSMTPInsecureSkipVerify) == "true" 226 | rootCmd.Flags().BoolVarP(&smtpInsecureSkipVerify, "smtp.insecure", "i", envInsecureSkipVerify, "Skip TLS verification with SMTP server"+commentStyle.Render("($"+PopSMTPInsecureSkipVerify+")")) 227 | envResendAPIKey := os.Getenv(ResendAPIKey) 228 | rootCmd.Flags().StringVarP(&resendAPIKey, "resend.key", "r", envResendAPIKey, "API key for the Resend.com"+commentStyle.Render("($"+ResendAPIKey+")")) 229 | 230 | rootCmd.CompletionOptions.HiddenDefaultCmd = true 231 | 232 | if len(CommitSHA) >= 7 { //nolint:gomnd 233 | vt := rootCmd.VersionTemplate() 234 | rootCmd.SetVersionTemplate(vt[:len(vt)-1] + " (" + CommitSHA[0:7] + ")\n") 235 | } 236 | if Version == "" { 237 | if info, ok := debug.ReadBuildInfo(); ok && info.Main.Sum != "" { 238 | Version = info.Main.Version 239 | } else { 240 | Version = "unknown (built from source)" 241 | } 242 | } 243 | rootCmd.Version = Version 244 | } 245 | 246 | func main() { 247 | err := rootCmd.Execute() 248 | if err != nil { 249 | os.Exit(1) 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /model.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | "time" 7 | 8 | "github.com/charmbracelet/bubbles/filepicker" 9 | "github.com/charmbracelet/bubbles/help" 10 | "github.com/charmbracelet/bubbles/key" 11 | "github.com/charmbracelet/bubbles/list" 12 | "github.com/charmbracelet/bubbles/spinner" 13 | "github.com/charmbracelet/bubbles/textarea" 14 | "github.com/charmbracelet/bubbles/textinput" 15 | tea "github.com/charmbracelet/bubbletea" 16 | "github.com/charmbracelet/x/exp/ordered" 17 | "github.com/resendlabs/resend-go" 18 | ) 19 | 20 | // State is the current state of the application. 21 | type State int 22 | 23 | const ( 24 | editingFrom State = iota 25 | editingTo 26 | editingCc 27 | editingBcc 28 | editingSubject 29 | editingBody 30 | editingAttachments 31 | hoveringSendButton 32 | pickingFile 33 | sendingEmail 34 | ) 35 | 36 | // DeliveryMethod is the method of delivery for the email. 37 | type DeliveryMethod int 38 | 39 | const ( 40 | // None is the default delivery method. 41 | None DeliveryMethod = iota 42 | // Resend uses https://resend.com to send an email. 43 | Resend 44 | // SMTP uses an SMTP server to send an email. 45 | SMTP 46 | // Unknown is set when the user has not chosen a single delivery method. 47 | // i.e. multiple delivery methods are set. 48 | Unknown 49 | ) 50 | 51 | // Model is Pop's application model. 52 | type Model struct { 53 | // state represents the current state of the application. 54 | state State 55 | 56 | // DeliveryMethod is whether we are using DeliveryMethod or Resend. 57 | DeliveryMethod DeliveryMethod 58 | 59 | // From represents the sender's email address. 60 | From textinput.Model 61 | // To represents the recipient's email address. 62 | // This can be a comma-separated list of addresses. 63 | To textinput.Model 64 | // Subject represents the email's subject. 65 | Subject textinput.Model 66 | // Body represents the email's body. 67 | // This can be written in markdown and will be converted to HTML. 68 | Body textarea.Model 69 | // Attachments represents the email's attachments. 70 | // This is a list of file paths which are picked with a filepicker. 71 | Attachments list.Model 72 | 73 | showCc bool 74 | Cc textinput.Model 75 | Bcc textinput.Model 76 | 77 | // filepicker is used to pick file attachments. 78 | filepicker filepicker.Model 79 | loadingSpinner spinner.Model 80 | help help.Model 81 | keymap KeyMap 82 | quitting bool 83 | abort bool 84 | err error 85 | } 86 | 87 | // NewModel returns a new model for the application. 88 | func NewModel(defaults resend.SendEmailRequest, deliveryMethod DeliveryMethod) Model { 89 | from := textinput.New() 90 | from.Prompt = "From " 91 | from.Placeholder = "me@example.com" 92 | from.PromptStyle = labelStyle.Copy() 93 | from.PromptStyle = labelStyle 94 | from.TextStyle = textStyle 95 | from.Cursor.Style = cursorStyle 96 | from.PlaceholderStyle = placeholderStyle 97 | from.SetValue(defaults.From) 98 | 99 | to := textinput.New() 100 | to.Prompt = "To " 101 | to.PromptStyle = labelStyle.Copy() 102 | to.Cursor.Style = cursorStyle 103 | to.PlaceholderStyle = placeholderStyle 104 | to.TextStyle = textStyle 105 | to.Placeholder = "you@example.com" 106 | to.SetValue(strings.Join(defaults.To, ToSeparator)) 107 | 108 | cc := textinput.New() 109 | cc.Prompt = "Cc " 110 | cc.PromptStyle = labelStyle.Copy() 111 | cc.Cursor.Style = cursorStyle 112 | cc.PlaceholderStyle = placeholderStyle 113 | cc.TextStyle = textStyle 114 | cc.Placeholder = "cc@example.com" 115 | cc.SetValue(strings.Join(defaults.Cc, ToSeparator)) 116 | 117 | bcc := textinput.New() 118 | bcc.Prompt = "Bcc " 119 | bcc.PromptStyle = labelStyle.Copy() 120 | bcc.Cursor.Style = cursorStyle 121 | bcc.PlaceholderStyle = placeholderStyle 122 | bcc.TextStyle = textStyle 123 | bcc.Placeholder = "bcc@example.com" 124 | bcc.SetValue(strings.Join(defaults.Bcc, ToSeparator)) 125 | 126 | subject := textinput.New() 127 | subject.Prompt = "Subject " 128 | subject.PromptStyle = labelStyle.Copy() 129 | subject.Cursor.Style = cursorStyle 130 | subject.PlaceholderStyle = placeholderStyle 131 | subject.TextStyle = textStyle 132 | subject.Placeholder = "Hello!" 133 | subject.SetValue(defaults.Subject) 134 | 135 | body := textarea.New() 136 | body.Placeholder = "# Email" 137 | body.ShowLineNumbers = false 138 | body.FocusedStyle.CursorLine = activeTextStyle 139 | body.FocusedStyle.Prompt = activeLabelStyle 140 | body.FocusedStyle.Text = activeTextStyle 141 | body.FocusedStyle.Placeholder = placeholderStyle 142 | body.BlurredStyle.CursorLine = textStyle 143 | body.BlurredStyle.Prompt = labelStyle 144 | body.BlurredStyle.Text = textStyle 145 | body.BlurredStyle.Placeholder = placeholderStyle 146 | body.Cursor.Style = cursorStyle 147 | body.CharLimit = 4000 148 | body.SetValue(defaults.Text) 149 | 150 | // Adjust for signature (if none, this is a no-op) 151 | body.CursorUp() 152 | body.CursorUp() 153 | 154 | body.Blur() 155 | 156 | // Decide which input to focus. 157 | var state State 158 | switch { 159 | case defaults.From == "": 160 | state = editingFrom 161 | case len(defaults.To) == 0: 162 | state = editingTo 163 | case defaults.Subject == "": 164 | state = editingSubject 165 | case defaults.Text == "": 166 | state = editingBody 167 | } 168 | 169 | attachments := list.New([]list.Item{}, attachmentDelegate{}, 0, 3) 170 | attachments.DisableQuitKeybindings() 171 | attachments.SetShowTitle(true) 172 | attachments.Title = "Attachments" 173 | attachments.Styles.Title = labelStyle 174 | attachments.Styles.TitleBar = labelStyle 175 | attachments.Styles.NoItems = placeholderStyle 176 | attachments.SetShowHelp(false) 177 | attachments.SetShowStatusBar(false) 178 | attachments.SetStatusBarItemName("attachment", "attachments") 179 | attachments.SetShowPagination(false) 180 | 181 | for _, a := range defaults.Attachments { 182 | attachments.InsertItem(0, attachment(a.Filename)) 183 | } 184 | 185 | picker := filepicker.New() 186 | picker.CurrentDirectory, _ = os.UserHomeDir() 187 | 188 | loadingSpinner := spinner.New() 189 | loadingSpinner.Style = activeLabelStyle 190 | loadingSpinner.Spinner = spinner.Dot 191 | 192 | m := Model{ 193 | state: state, 194 | From: from, 195 | To: to, 196 | showCc: len(cc.Value()) > 0 || len(bcc.Value()) > 0, 197 | Cc: cc, 198 | Bcc: bcc, 199 | Subject: subject, 200 | Body: body, 201 | Attachments: attachments, 202 | filepicker: picker, 203 | help: help.New(), 204 | keymap: DefaultKeybinds(), 205 | loadingSpinner: loadingSpinner, 206 | DeliveryMethod: deliveryMethod, 207 | } 208 | 209 | m.focusActiveInput() 210 | 211 | return m 212 | } 213 | 214 | // Init initializes the model. 215 | func (m Model) Init() tea.Cmd { 216 | return tea.Batch( 217 | m.From.Cursor.BlinkCmd(), 218 | ) 219 | } 220 | 221 | type clearErrMsg struct{} 222 | 223 | func clearErrAfter(d time.Duration) tea.Cmd { 224 | return tea.Tick(d, func(t time.Time) tea.Msg { 225 | return clearErrMsg{} 226 | }) 227 | } 228 | 229 | // Update is the update loop for the model. 230 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 231 | switch msg := msg.(type) { 232 | case sendEmailSuccessMsg: 233 | m.quitting = true 234 | return m, tea.Quit 235 | case sendEmailFailureMsg: 236 | m.blurInputs() 237 | m.state = editingFrom 238 | m.focusActiveInput() 239 | m.err = msg 240 | return m, clearErrAfter(10 * time.Second) 241 | case clearErrMsg: 242 | m.err = nil 243 | case tea.KeyMsg: 244 | switch { 245 | case key.Matches(msg, m.keymap.NextInput): 246 | m.blurInputs() 247 | switch m.state { 248 | case editingFrom: 249 | m.state = editingTo 250 | m.To.Focus() 251 | case editingTo: 252 | if m.showCc { 253 | m.state = editingCc 254 | } else { 255 | m.state = editingSubject 256 | } 257 | case editingCc: 258 | m.state = editingBcc 259 | case editingBcc: 260 | m.state = editingSubject 261 | case editingSubject: 262 | m.state = editingBody 263 | case editingBody: 264 | m.state = editingAttachments 265 | case editingAttachments: 266 | m.state = hoveringSendButton 267 | case hoveringSendButton: 268 | m.state = editingFrom 269 | } 270 | m.focusActiveInput() 271 | 272 | case key.Matches(msg, m.keymap.PrevInput): 273 | m.blurInputs() 274 | switch m.state { 275 | case editingFrom: 276 | m.state = hoveringSendButton 277 | case editingTo: 278 | m.state = editingFrom 279 | case editingCc: 280 | m.state = editingTo 281 | case editingBcc: 282 | m.state = editingCc 283 | case editingSubject: 284 | if m.showCc { 285 | m.state = editingBcc 286 | } else { 287 | m.state = editingTo 288 | } 289 | case editingBody: 290 | m.state = editingSubject 291 | case editingAttachments: 292 | m.state = editingBody 293 | case hoveringSendButton: 294 | m.state = editingAttachments 295 | } 296 | m.focusActiveInput() 297 | 298 | case key.Matches(msg, m.keymap.Back): 299 | m.state = editingAttachments 300 | m.updateKeymap() 301 | return m, nil 302 | case key.Matches(msg, m.keymap.Send): 303 | m.state = sendingEmail 304 | return m, tea.Batch( 305 | m.loadingSpinner.Tick, 306 | m.sendEmailCmd(), 307 | ) 308 | case key.Matches(msg, m.keymap.Attach): 309 | m.state = pickingFile 310 | return m, m.filepicker.Init() 311 | case key.Matches(msg, m.keymap.Unattach): 312 | m.Attachments.RemoveItem(m.Attachments.Index()) 313 | m.Attachments.SetHeight(ordered.Max(len(m.Attachments.Items()), 1) + 2) 314 | case key.Matches(msg, m.keymap.Quit): 315 | m.quitting = true 316 | m.abort = true 317 | return m, tea.Quit 318 | } 319 | } 320 | 321 | m.updateKeymap() 322 | 323 | var cmds []tea.Cmd 324 | var cmd tea.Cmd 325 | m.From, cmd = m.From.Update(msg) 326 | cmds = append(cmds, cmd) 327 | m.To, cmd = m.To.Update(msg) 328 | cmds = append(cmds, cmd) 329 | if m.showCc { 330 | m.Cc, cmd = m.Cc.Update(msg) 331 | cmds = append(cmds, cmd) 332 | m.Bcc, cmd = m.Bcc.Update(msg) 333 | cmds = append(cmds, cmd) 334 | } 335 | m.Subject, cmd = m.Subject.Update(msg) 336 | cmds = append(cmds, cmd) 337 | m.Body, cmd = m.Body.Update(msg) 338 | cmds = append(cmds, cmd) 339 | m.filepicker, cmd = m.filepicker.Update(msg) 340 | cmds = append(cmds, cmd) 341 | 342 | switch m.state { 343 | case pickingFile: 344 | if didSelect, path := m.filepicker.DidSelectFile(msg); didSelect { 345 | m.Attachments.InsertItem(0, attachment(path)) 346 | m.Attachments.SetHeight(len(m.Attachments.Items()) + 2) 347 | m.state = editingAttachments 348 | m.updateKeymap() 349 | } 350 | case editingAttachments: 351 | m.Attachments, cmd = m.Attachments.Update(msg) 352 | cmds = append(cmds, cmd) 353 | case sendingEmail: 354 | m.loadingSpinner, cmd = m.loadingSpinner.Update(msg) 355 | cmds = append(cmds, cmd) 356 | } 357 | 358 | m.help, cmd = m.help.Update(msg) 359 | cmds = append(cmds, cmd) 360 | 361 | return m, tea.Batch(cmds...) 362 | } 363 | 364 | func (m *Model) blurInputs() { 365 | m.From.Blur() 366 | m.To.Blur() 367 | m.Subject.Blur() 368 | m.Body.Blur() 369 | if m.showCc { 370 | m.Cc.Blur() 371 | m.Bcc.Blur() 372 | } 373 | m.From.PromptStyle = labelStyle 374 | m.To.PromptStyle = labelStyle 375 | if m.showCc { 376 | m.Cc.PromptStyle = labelStyle 377 | m.Cc.TextStyle = textStyle 378 | m.Bcc.PromptStyle = labelStyle 379 | m.Bcc.TextStyle = textStyle 380 | } 381 | m.Subject.PromptStyle = labelStyle 382 | m.From.TextStyle = textStyle 383 | m.To.TextStyle = textStyle 384 | m.Subject.TextStyle = textStyle 385 | m.Attachments.Styles.Title = labelStyle 386 | m.Attachments.SetDelegate(attachmentDelegate{false}) 387 | } 388 | 389 | func (m *Model) focusActiveInput() { 390 | switch m.state { 391 | case editingFrom: 392 | m.From.PromptStyle = activeLabelStyle 393 | m.From.TextStyle = activeTextStyle 394 | m.From.Focus() 395 | m.From.CursorEnd() 396 | case editingTo: 397 | m.To.PromptStyle = activeLabelStyle 398 | m.To.TextStyle = activeTextStyle 399 | m.To.Focus() 400 | m.To.CursorEnd() 401 | case editingCc: 402 | m.Cc.PromptStyle = activeLabelStyle 403 | m.Cc.TextStyle = activeTextStyle 404 | m.Cc.Focus() 405 | m.Cc.CursorEnd() 406 | case editingBcc: 407 | m.Bcc.PromptStyle = activeLabelStyle 408 | m.Bcc.TextStyle = activeTextStyle 409 | m.Bcc.Focus() 410 | m.Bcc.CursorEnd() 411 | case editingSubject: 412 | m.Subject.PromptStyle = activeLabelStyle 413 | m.Subject.TextStyle = activeTextStyle 414 | m.Subject.Focus() 415 | m.Subject.CursorEnd() 416 | case editingBody: 417 | m.Body.Focus() 418 | m.Body.CursorEnd() 419 | case editingAttachments: 420 | m.Attachments.Styles.Title = activeLabelStyle 421 | m.Attachments.SetDelegate(attachmentDelegate{true}) 422 | } 423 | } 424 | 425 | // View displays the application. 426 | func (m Model) View() string { 427 | if m.quitting { 428 | return "" 429 | } 430 | 431 | switch m.state { 432 | case pickingFile: 433 | return "\n" + activeLabelStyle.Render("Attachments") + " " + commentStyle.Render(m.filepicker.CurrentDirectory) + 434 | "\n\n" + m.filepicker.View() 435 | case sendingEmail: 436 | return "\n " + m.loadingSpinner.View() + "Sending email" 437 | } 438 | 439 | var s strings.Builder 440 | 441 | s.WriteString(m.From.View()) 442 | s.WriteString("\n") 443 | s.WriteString(m.To.View()) 444 | s.WriteString("\n") 445 | if m.showCc { 446 | s.WriteString(m.Cc.View()) 447 | s.WriteString("\n") 448 | s.WriteString(m.Bcc.View()) 449 | s.WriteString("\n") 450 | } 451 | s.WriteString(m.Subject.View()) 452 | s.WriteString("\n\n") 453 | s.WriteString(m.Body.View()) 454 | s.WriteString("\n\n") 455 | s.WriteString(m.Attachments.View()) 456 | s.WriteString("\n") 457 | if m.state == hoveringSendButton && m.canSend() { 458 | s.WriteString(sendButtonActiveStyle.Render("Send")) 459 | } else if m.state == hoveringSendButton { 460 | s.WriteString(sendButtonInactiveStyle.Render("Send")) 461 | } else { 462 | s.WriteString(sendButtonStyle.Render("Send")) 463 | } 464 | s.WriteString("\n\n") 465 | s.WriteString(m.help.View(m.keymap)) 466 | 467 | if m.err != nil { 468 | s.WriteString("\n\n") 469 | s.WriteString(errorStyle.Render(m.err.Error())) 470 | } 471 | 472 | return paddedStyle.Render(s.String()) 473 | } 474 | --------------------------------------------------------------------------------