1163 lines
29 KiB
Go
1163 lines
29 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/textproto"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
defaultBaseURL = "https://api.mailthisforme.com"
|
|
maxPDFBytes = 10 * 1024 * 1024
|
|
)
|
|
|
|
var errNotPDF = errors.New("file does not start with a PDF header")
|
|
|
|
type appState struct {
|
|
client *apiClient
|
|
reader *bufio.Reader
|
|
documentPath string
|
|
documentSize int64
|
|
pageCount int
|
|
documentIssues []string
|
|
recipient Address
|
|
sender Address
|
|
senderMode senderMode
|
|
returnAddressID string
|
|
lastRequestKey string
|
|
lastMailID string
|
|
}
|
|
|
|
type senderMode int
|
|
|
|
const (
|
|
senderDefault senderMode = iota
|
|
senderReturnAddressID
|
|
senderDirect
|
|
)
|
|
|
|
type apiClient struct {
|
|
baseURL string
|
|
apiKey string
|
|
httpClient *http.Client
|
|
}
|
|
|
|
type Address struct {
|
|
Name string `json:"name,omitempty"`
|
|
Company string `json:"company,omitempty"`
|
|
Address1 string `json:"address1,omitempty"`
|
|
Address2 string `json:"address2,omitempty"`
|
|
City string `json:"city,omitempty"`
|
|
State string `json:"state,omitempty"`
|
|
PostalCode string `json:"postal_code,omitempty"`
|
|
Country string `json:"country,omitempty"`
|
|
Phone string `json:"phone,omitempty"`
|
|
}
|
|
|
|
type returnAddressList struct {
|
|
Data []returnAddress `json:"data"`
|
|
}
|
|
|
|
type returnAddress struct {
|
|
ID string `json:"id"`
|
|
Label string `json:"label"`
|
|
Name string `json:"name"`
|
|
Company string `json:"company"`
|
|
Address1 string `json:"address1"`
|
|
Address2 string `json:"address2"`
|
|
City string `json:"city"`
|
|
State string `json:"state"`
|
|
PostalCode string `json:"postal_code"`
|
|
Country string `json:"country"`
|
|
IsDefault bool `json:"is_default"`
|
|
}
|
|
|
|
type fileEntry struct {
|
|
name string
|
|
path string
|
|
isDir bool
|
|
}
|
|
|
|
type pdfAnalysis struct {
|
|
PageCount int
|
|
Issues []string
|
|
Warnings []string
|
|
Dimensions []pageDimension
|
|
}
|
|
|
|
type pageDimension struct {
|
|
Page int
|
|
Width float64
|
|
Height float64
|
|
IsLetter bool
|
|
}
|
|
|
|
func main() {
|
|
fileFlag := flag.String("f", "", "PDF document to preselect")
|
|
flag.Parse()
|
|
loadDotEnv(".env")
|
|
|
|
apiKey := firstEnv("MAILTHISFORME_API_KEY", "MTFM_API_KEY")
|
|
baseURL := firstEnv("MAILTHISFORME_BASE_URL", "MTFM_BASE_URL")
|
|
if baseURL == "" {
|
|
baseURL = defaultBaseURL
|
|
}
|
|
|
|
timeout := 60 * time.Second
|
|
if raw := os.Getenv("MTFM_HTTP_TIMEOUT"); raw != "" {
|
|
parsed, err := time.ParseDuration(raw)
|
|
if err == nil && parsed > 0 {
|
|
timeout = parsed
|
|
}
|
|
}
|
|
|
|
state := &appState{
|
|
client: &apiClient{
|
|
baseURL: strings.TrimRight(baseURL, "/"),
|
|
apiKey: apiKey,
|
|
httpClient: &http.Client{
|
|
Timeout: timeout,
|
|
},
|
|
},
|
|
reader: bufio.NewReader(os.Stdin),
|
|
senderMode: senderDefault,
|
|
}
|
|
|
|
if strings.TrimSpace(*fileFlag) != "" {
|
|
message, err := state.setDocumentPath(*fileFlag)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: -f: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if message != "" {
|
|
fmt.Fprintf(os.Stderr, "warning: %s\n", message)
|
|
}
|
|
}
|
|
|
|
if err := state.run(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func (a *appState) run() error {
|
|
for {
|
|
a.drawHome()
|
|
choice := a.prompt("Choose an option")
|
|
|
|
switch strings.TrimSpace(choice) {
|
|
case "1":
|
|
a.selectDocument()
|
|
case "2":
|
|
a.setRecipient()
|
|
case "3":
|
|
a.setSender()
|
|
case "4":
|
|
a.quoteDocument()
|
|
case "5":
|
|
a.sendDocument()
|
|
case "6":
|
|
a.showBalance()
|
|
case "7":
|
|
a.checkStatus()
|
|
case "c", "C":
|
|
a.convertSelectedDocument()
|
|
case "8", "q", "Q":
|
|
fmt.Println("Goodbye.")
|
|
return nil
|
|
default:
|
|
a.pause("Unknown option.")
|
|
}
|
|
}
|
|
}
|
|
|
|
func (a *appState) drawHome() {
|
|
clearScreen()
|
|
fmt.Println("MailThisForMe TUI")
|
|
fmt.Println(strings.Repeat("=", 19))
|
|
fmt.Printf("API: %s\n", a.client.baseURL)
|
|
fmt.Printf("API key: %s\n", maskSecret(a.client.apiKey))
|
|
fmt.Printf("PDF: %s\n", a.documentSummary())
|
|
fmt.Printf("Pages: %s\n", pageSummary(a.pageCount))
|
|
if len(a.documentIssues) > 0 {
|
|
fmt.Printf("PDF check: %s\n", strings.Join(a.documentIssues, " "))
|
|
}
|
|
fmt.Printf("To: %s\n", addressOneLine(a.recipient))
|
|
fmt.Printf("Sender: %s\n", a.senderSummary())
|
|
if a.lastMailID != "" {
|
|
fmt.Printf("Last job: %s\n", a.lastMailID)
|
|
}
|
|
fmt.Println()
|
|
fmt.Println("1. Select PDF document")
|
|
fmt.Println("2. Enter recipient address")
|
|
fmt.Println("3. Choose sender / return address")
|
|
fmt.Println("4. Quote selected document")
|
|
fmt.Println("5. Send selected document")
|
|
fmt.Println("6. Show balance")
|
|
fmt.Println("7. Check job status")
|
|
fmt.Println("C. Convert selected PDF to US Letter")
|
|
fmt.Println("8. Quit")
|
|
fmt.Println()
|
|
}
|
|
|
|
func (a *appState) selectDocument() {
|
|
dir, err := os.Getwd()
|
|
if err != nil {
|
|
a.pause(fmt.Sprintf("Could not read current directory: %v", err))
|
|
return
|
|
}
|
|
if a.documentPath != "" {
|
|
dir = filepath.Dir(a.documentPath)
|
|
}
|
|
|
|
for {
|
|
entries, err := readFileEntries(dir)
|
|
if err != nil {
|
|
a.pause(fmt.Sprintf("Could not list %s: %v", dir, err))
|
|
return
|
|
}
|
|
|
|
clearScreen()
|
|
fmt.Println("Select PDF Document")
|
|
fmt.Println(strings.Repeat("=", 19))
|
|
fmt.Printf("Directory: %s\n\n", dir)
|
|
for i, entry := range entries {
|
|
label := entry.name
|
|
if entry.isDir {
|
|
label += string(os.PathSeparator)
|
|
}
|
|
fmt.Printf("%2d. %s\n", i+1, label)
|
|
}
|
|
fmt.Println()
|
|
fmt.Println("Type a number to open/select, a path to select directly, or blank to cancel.")
|
|
|
|
raw := strings.TrimSpace(a.prompt("Selection"))
|
|
if raw == "" {
|
|
return
|
|
}
|
|
|
|
var selected string
|
|
if index, err := strconv.Atoi(raw); err == nil {
|
|
if index < 1 || index > len(entries) {
|
|
a.pause("Invalid selection.")
|
|
continue
|
|
}
|
|
entry := entries[index-1]
|
|
if entry.isDir {
|
|
dir = entry.path
|
|
continue
|
|
}
|
|
selected = entry.path
|
|
} else {
|
|
selected = expandPath(raw)
|
|
if !filepath.IsAbs(selected) {
|
|
selected = filepath.Join(dir, selected)
|
|
}
|
|
if info, err := os.Stat(selected); err == nil && info.IsDir() {
|
|
dir = selected
|
|
continue
|
|
}
|
|
}
|
|
|
|
message, err := a.setDocumentPath(selected)
|
|
if err != nil {
|
|
a.pause(fmt.Sprintf("Could not select document: %v", err))
|
|
continue
|
|
}
|
|
if message != "" {
|
|
a.pause(fmt.Sprintf("Selected %s (%s, %s). %s", a.documentPath, humanBytes(a.documentSize), pageSummary(a.pageCount), message))
|
|
} else {
|
|
a.pause(fmt.Sprintf("Selected %s (%s, %s).", a.documentPath, humanBytes(a.documentSize), pageSummary(a.pageCount)))
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
func (a *appState) setDocumentPath(path string) (string, error) {
|
|
path = expandPath(path)
|
|
absPath, err := filepath.Abs(path)
|
|
if err == nil {
|
|
path = absPath
|
|
}
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return "", fmt.Errorf("could not read file: %w", err)
|
|
}
|
|
if info.IsDir() {
|
|
return "", errors.New("that path is a directory, not a PDF")
|
|
}
|
|
if !strings.EqualFold(filepath.Ext(path), ".pdf") {
|
|
return "", errors.New("selected file must have a .pdf extension")
|
|
}
|
|
if info.Size() > maxPDFBytes {
|
|
return "", fmt.Errorf("file is %s; MailThisForMe allows PDFs up to 10 MB", humanBytes(info.Size()))
|
|
}
|
|
pages, err := estimatePDFPageCount(path)
|
|
pageWarning := ""
|
|
if err != nil {
|
|
if errors.Is(err, errNotPDF) {
|
|
return "", err
|
|
}
|
|
pageWarning = fmt.Sprintf("%v.", err)
|
|
pages = 0
|
|
}
|
|
|
|
analysis, err := analyzePDF(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
a.documentPath = path
|
|
a.documentSize = info.Size()
|
|
a.pageCount = pages
|
|
if analysis.PageCount > 0 {
|
|
a.pageCount = analysis.PageCount
|
|
}
|
|
a.documentIssues = append([]string{}, analysis.Issues...)
|
|
|
|
messages := append([]string{}, analysis.Issues...)
|
|
messages = append(messages, analysis.Warnings...)
|
|
if pageWarning != "" {
|
|
messages = append(messages, pageWarning)
|
|
}
|
|
return strings.Join(messages, " "), nil
|
|
}
|
|
|
|
func (a *appState) setRecipient() {
|
|
clearScreen()
|
|
fmt.Println("Recipient Address")
|
|
fmt.Println(strings.Repeat("=", 17))
|
|
addr := a.promptAddress(a.recipient)
|
|
a.recipient = addr
|
|
a.pause("Recipient updated.")
|
|
}
|
|
|
|
func (a *appState) setSender() {
|
|
for {
|
|
clearScreen()
|
|
fmt.Println("Sender / Return Address")
|
|
fmt.Println(strings.Repeat("=", 23))
|
|
fmt.Println("1. Use account default return address")
|
|
fmt.Println("2. Enter return address id")
|
|
fmt.Println("3. List saved return addresses")
|
|
fmt.Println("4. Enter direct sender address")
|
|
fmt.Println("5. Back")
|
|
fmt.Println()
|
|
choice := a.prompt("Choose an option")
|
|
switch strings.TrimSpace(choice) {
|
|
case "1":
|
|
a.senderMode = senderDefault
|
|
a.returnAddressID = ""
|
|
a.pause("Sender will use the account default return address.")
|
|
return
|
|
case "2":
|
|
id := strings.TrimSpace(a.prompt("Return address id (raddr_...)"))
|
|
if id == "" {
|
|
a.pause("No id entered.")
|
|
continue
|
|
}
|
|
a.senderMode = senderReturnAddressID
|
|
a.returnAddressID = id
|
|
a.pause("Return address id set.")
|
|
return
|
|
case "3":
|
|
a.chooseReturnAddress()
|
|
return
|
|
case "4":
|
|
clearScreen()
|
|
fmt.Println("Direct Sender Address")
|
|
fmt.Println(strings.Repeat("=", 21))
|
|
a.sender = a.promptAddress(a.sender)
|
|
a.senderMode = senderDirect
|
|
a.returnAddressID = ""
|
|
a.pause("Direct sender address updated.")
|
|
return
|
|
case "5":
|
|
return
|
|
default:
|
|
a.pause("Unknown option.")
|
|
}
|
|
}
|
|
}
|
|
|
|
func (a *appState) chooseReturnAddress() {
|
|
if !a.client.hasAPIKey() {
|
|
a.pause("API key is required to list return addresses.")
|
|
return
|
|
}
|
|
|
|
var list returnAddressList
|
|
if err := a.client.getJSON(context.Background(), "/v1/return-addresses", true, &list); err != nil {
|
|
a.pause(fmt.Sprintf("Could not list return addresses: %v", err))
|
|
return
|
|
}
|
|
if len(list.Data) == 0 {
|
|
a.pause("No saved return addresses found.")
|
|
return
|
|
}
|
|
|
|
clearScreen()
|
|
fmt.Println("Saved Return Addresses")
|
|
fmt.Println(strings.Repeat("=", 22))
|
|
for i, item := range list.Data {
|
|
defaultMark := ""
|
|
if item.IsDefault {
|
|
defaultMark = " [default]"
|
|
}
|
|
fmt.Printf("%d. %s%s\n", i+1, item.Label, defaultMark)
|
|
fmt.Printf(" %s, %s, %s %s %s (%s)\n", item.Name, item.Address1, item.City, item.State, item.PostalCode, item.ID)
|
|
}
|
|
fmt.Println()
|
|
raw := a.prompt("Choose a number, or blank to cancel")
|
|
if strings.TrimSpace(raw) == "" {
|
|
return
|
|
}
|
|
index, err := strconv.Atoi(strings.TrimSpace(raw))
|
|
if err != nil || index < 1 || index > len(list.Data) {
|
|
a.pause("Invalid selection.")
|
|
return
|
|
}
|
|
a.senderMode = senderReturnAddressID
|
|
a.returnAddressID = list.Data[index-1].ID
|
|
a.pause(fmt.Sprintf("Using %s.", a.returnAddressID))
|
|
}
|
|
|
|
func (a *appState) quoteDocument() {
|
|
pageCount := a.pageCount
|
|
if pageCount > 0 {
|
|
if !confirm(a, "Use estimated "+pageSummary(pageCount)) {
|
|
pageCount = 0
|
|
}
|
|
}
|
|
if pageCount <= 0 {
|
|
pageCount = a.promptPageCount()
|
|
if pageCount <= 0 {
|
|
return
|
|
}
|
|
}
|
|
if pageCount > 10 {
|
|
a.pause(fmt.Sprintf("Estimated %d pages. MailThisForMe quotes accept 1-10 pages.", pageCount))
|
|
return
|
|
}
|
|
|
|
payload := map[string]int{"page_count": pageCount}
|
|
var out map[string]any
|
|
if err := a.client.postJSON(context.Background(), "/v1/mail/quote", false, payload, &out); err != nil {
|
|
a.pause(fmt.Sprintf("Quote failed: %v", err))
|
|
return
|
|
}
|
|
|
|
clearScreen()
|
|
fmt.Println("Quote")
|
|
fmt.Println(strings.Repeat("=", 5))
|
|
fmt.Println(formatAPIObject(out))
|
|
a.pause("")
|
|
}
|
|
|
|
func (a *appState) sendDocument() {
|
|
if err := a.validateSendInputs(); err != nil {
|
|
a.pause(err.Error())
|
|
return
|
|
}
|
|
if !a.client.hasAPIKey() {
|
|
a.pause("API key is required to send mail.")
|
|
return
|
|
}
|
|
|
|
clearScreen()
|
|
fmt.Println("Send Document")
|
|
fmt.Println(strings.Repeat("=", 13))
|
|
fmt.Printf("PDF: %s\n", a.documentSummary())
|
|
fmt.Printf("To: %s\n", addressOneLine(a.recipient))
|
|
fmt.Printf("Sender: %s\n", a.senderSummary())
|
|
fmt.Println()
|
|
fmt.Println("MailThisForMe preview jobs are queued in test mode and do not physically mail automatically.")
|
|
if !confirm(a, "Submit this document?") {
|
|
return
|
|
}
|
|
|
|
requestKey := a.lastRequestKey
|
|
if requestKey == "" || !confirm(a, "Reuse previous request key?") {
|
|
requestKey = newRequestKey()
|
|
}
|
|
a.lastRequestKey = requestKey
|
|
|
|
var out map[string]any
|
|
err := a.client.sendMail(context.Background(), a.documentPath, requestKey, a.recipient, a.senderMode, a.returnAddressID, a.sender, &out)
|
|
if err != nil {
|
|
a.pause(fmt.Sprintf("Send failed: %v\nRequest key: %s", err, requestKey))
|
|
return
|
|
}
|
|
|
|
if id, ok := out["id"].(string); ok {
|
|
a.lastMailID = id
|
|
}
|
|
|
|
clearScreen()
|
|
fmt.Println("Sent")
|
|
fmt.Println(strings.Repeat("=", 4))
|
|
fmt.Printf("Request key: %s\n\n", requestKey)
|
|
fmt.Println(formatAPIObject(out))
|
|
a.pause("")
|
|
}
|
|
|
|
func (a *appState) showBalance() {
|
|
if !a.client.hasAPIKey() {
|
|
a.pause("API key is required to read balance.")
|
|
return
|
|
}
|
|
|
|
var out map[string]any
|
|
if err := a.client.getJSON(context.Background(), "/v1/balance", true, &out); err != nil {
|
|
a.pause(fmt.Sprintf("Balance request failed: %v", err))
|
|
return
|
|
}
|
|
|
|
clearScreen()
|
|
fmt.Println("Balance")
|
|
fmt.Println(strings.Repeat("=", 7))
|
|
fmt.Println(formatAPIObject(out))
|
|
a.pause("")
|
|
}
|
|
|
|
func (a *appState) checkStatus() {
|
|
if !a.client.hasAPIKey() {
|
|
a.pause("API key is required to check status.")
|
|
return
|
|
}
|
|
|
|
defaultID := a.lastMailID
|
|
prompt := "Mail job id"
|
|
if defaultID != "" {
|
|
prompt += " [" + defaultID + "]"
|
|
}
|
|
id := strings.TrimSpace(a.prompt(prompt))
|
|
if id == "" {
|
|
id = defaultID
|
|
}
|
|
if id == "" {
|
|
a.pause("No mail job id entered.")
|
|
return
|
|
}
|
|
|
|
var out map[string]any
|
|
if err := a.client.getJSON(context.Background(), "/v1/mail/"+id, true, &out); err != nil {
|
|
a.pause(fmt.Sprintf("Status request failed: %v", err))
|
|
return
|
|
}
|
|
|
|
clearScreen()
|
|
fmt.Println("Mail Job Status")
|
|
fmt.Println(strings.Repeat("=", 15))
|
|
fmt.Println(formatAPIObject(out))
|
|
a.pause("")
|
|
}
|
|
|
|
func (a *appState) convertSelectedDocument() {
|
|
if a.documentPath == "" {
|
|
a.pause("Select a PDF document first.")
|
|
return
|
|
}
|
|
if _, err := exec.LookPath("gs"); err != nil {
|
|
a.pause("Ghostscript is not available on PATH. Install gs or export the PDF as US Letter from the source application.")
|
|
return
|
|
}
|
|
|
|
ext := filepath.Ext(a.documentPath)
|
|
defaultOutput := strings.TrimSuffix(a.documentPath, ext) + "-letter" + ext
|
|
output := a.promptDefault("Output PDF", defaultOutput)
|
|
output = expandPath(output)
|
|
if !filepath.IsAbs(output) {
|
|
output = filepath.Join(filepath.Dir(a.documentPath), output)
|
|
}
|
|
if output == a.documentPath {
|
|
a.pause("Output path must be different from the selected PDF.")
|
|
return
|
|
}
|
|
if _, err := os.Stat(output); err == nil {
|
|
if !confirm(a, "Output exists. Overwrite") {
|
|
return
|
|
}
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
a.pause(fmt.Sprintf("Could not inspect output path: %v", err))
|
|
return
|
|
}
|
|
|
|
cmd := exec.Command(
|
|
"gs",
|
|
"-o", output,
|
|
"-sDEVICE=pdfwrite",
|
|
"-sPAPERSIZE=letter",
|
|
"-dFIXEDMEDIA",
|
|
"-dPDFFitPage",
|
|
a.documentPath,
|
|
)
|
|
var stderr bytes.Buffer
|
|
cmd.Stderr = &stderr
|
|
if err := cmd.Run(); err != nil {
|
|
details := strings.TrimSpace(stderr.String())
|
|
if details != "" {
|
|
a.pause(fmt.Sprintf("Ghostscript conversion failed: %v\n%s", err, details))
|
|
} else {
|
|
a.pause(fmt.Sprintf("Ghostscript conversion failed: %v", err))
|
|
}
|
|
return
|
|
}
|
|
|
|
message, err := a.setDocumentPath(output)
|
|
if err != nil {
|
|
a.pause(fmt.Sprintf("Created %s, but could not select it: %v", output, err))
|
|
return
|
|
}
|
|
if message != "" {
|
|
a.pause(fmt.Sprintf("Created and selected %s. %s", output, message))
|
|
return
|
|
}
|
|
a.pause(fmt.Sprintf("Created and selected %s.", output))
|
|
}
|
|
|
|
func (a *appState) validateSendInputs() error {
|
|
if a.documentPath == "" {
|
|
return errors.New("select a PDF document first")
|
|
}
|
|
if err := validateRequiredAddress(a.recipient, "recipient"); err != nil {
|
|
return err
|
|
}
|
|
if a.senderMode == senderReturnAddressID && strings.TrimSpace(a.returnAddressID) == "" {
|
|
return errors.New("enter a return address id or choose the default sender")
|
|
}
|
|
if a.senderMode == senderDirect {
|
|
if err := validateRequiredAddress(a.sender, "sender"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if a.pageCount > 10 {
|
|
return fmt.Errorf("selected PDF is estimated at %d pages; MailThisForMe allows up to 10", a.pageCount)
|
|
}
|
|
if len(a.documentIssues) > 0 {
|
|
return fmt.Errorf("selected PDF failed local preflight: %s", strings.Join(a.documentIssues, " "))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *appState) promptAddress(current Address) Address {
|
|
addr := current
|
|
addr.Name = a.promptDefault("Name", addr.Name)
|
|
addr.Company = a.promptDefault("Company (optional)", addr.Company)
|
|
addr.Address1 = a.promptDefault("Address line 1", addr.Address1)
|
|
addr.Address2 = a.promptDefault("Address line 2 (optional)", addr.Address2)
|
|
addr.City = a.promptDefault("City", addr.City)
|
|
addr.State = strings.ToUpper(a.promptDefault("State", addr.State))
|
|
addr.PostalCode = a.promptDefault("Postal code", addr.PostalCode)
|
|
addr.Country = strings.ToUpper(a.promptDefault("Country", defaultString(addr.Country, "US")))
|
|
addr.Phone = a.promptDefault("Phone (optional)", addr.Phone)
|
|
return addr
|
|
}
|
|
|
|
func (a *appState) promptPageCount() int {
|
|
for {
|
|
raw := strings.TrimSpace(a.prompt("Page count (1-10)"))
|
|
if raw == "" {
|
|
return 0
|
|
}
|
|
n, err := strconv.Atoi(raw)
|
|
if err == nil && n >= 1 && n <= 10 {
|
|
return n
|
|
}
|
|
fmt.Println("Enter a number from 1 to 10, or blank to cancel.")
|
|
}
|
|
}
|
|
|
|
func (a *appState) prompt(label string) string {
|
|
fmt.Print(label + ": ")
|
|
text, _ := a.reader.ReadString('\n')
|
|
return strings.TrimRight(text, "\r\n")
|
|
}
|
|
|
|
func (a *appState) promptDefault(label, current string) string {
|
|
if current != "" {
|
|
label += " [" + current + "]"
|
|
}
|
|
value := strings.TrimSpace(a.prompt(label))
|
|
if value == "" {
|
|
return current
|
|
}
|
|
return value
|
|
}
|
|
|
|
func (a *appState) pause(message string) {
|
|
if message != "" {
|
|
fmt.Println()
|
|
fmt.Println(message)
|
|
}
|
|
fmt.Println()
|
|
fmt.Print("Press Enter to continue...")
|
|
_, _ = a.reader.ReadString('\n')
|
|
}
|
|
|
|
func readFileEntries(dir string) ([]fileEntry, error) {
|
|
parent := filepath.Clean(filepath.Join(dir, ".."))
|
|
entries := []fileEntry{{
|
|
name: "..",
|
|
path: parent,
|
|
isDir: true,
|
|
}}
|
|
|
|
items, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, item := range items {
|
|
entries = append(entries, fileEntry{
|
|
name: item.Name(),
|
|
path: filepath.Join(dir, item.Name()),
|
|
isDir: item.IsDir(),
|
|
})
|
|
}
|
|
|
|
sort.SliceStable(entries[1:], func(i, j int) bool {
|
|
left := entries[i+1]
|
|
right := entries[j+1]
|
|
if left.isDir != right.isDir {
|
|
return left.isDir
|
|
}
|
|
return strings.ToLower(left.name) < strings.ToLower(right.name)
|
|
})
|
|
|
|
return entries, nil
|
|
}
|
|
|
|
func (a *appState) documentSummary() string {
|
|
if a.documentPath == "" {
|
|
return "not selected"
|
|
}
|
|
return fmt.Sprintf("%s (%s)", a.documentPath, humanBytes(a.documentSize))
|
|
}
|
|
|
|
func (a *appState) senderSummary() string {
|
|
switch a.senderMode {
|
|
case senderDefault:
|
|
return "account default return address"
|
|
case senderReturnAddressID:
|
|
return "return address " + a.returnAddressID
|
|
case senderDirect:
|
|
return addressOneLine(a.sender)
|
|
default:
|
|
return "not set"
|
|
}
|
|
}
|
|
|
|
func (c *apiClient) hasAPIKey() bool {
|
|
return strings.TrimSpace(c.apiKey) != ""
|
|
}
|
|
|
|
func (c *apiClient) getJSON(ctx context.Context, path string, authenticated bool, out any) error {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if authenticated {
|
|
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
|
}
|
|
return c.do(req, out)
|
|
}
|
|
|
|
func (c *apiClient) postJSON(ctx context.Context, path string, authenticated bool, payload any, out any) error {
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(body))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if authenticated {
|
|
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
|
}
|
|
return c.do(req, out)
|
|
}
|
|
|
|
func (c *apiClient) sendMail(ctx context.Context, documentPath, requestKey string, recipient Address, mode senderMode, returnAddressID string, sender Address, out any) error {
|
|
var body bytes.Buffer
|
|
writer := multipart.NewWriter(&body)
|
|
|
|
if err := addFilePart(writer, "document", documentPath); err != nil {
|
|
return err
|
|
}
|
|
addAddressFields(writer, "recipient", recipient)
|
|
|
|
switch mode {
|
|
case senderReturnAddressID:
|
|
_ = writer.WriteField("return_address_id", returnAddressID)
|
|
case senderDirect:
|
|
addAddressFields(writer, "sender", sender)
|
|
}
|
|
|
|
metadata := `{"source":"mtfm-mailer-tui"}`
|
|
_ = writer.WriteField("metadata", metadata)
|
|
_ = writer.Close()
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/mail/send", &body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
req.Header.Set("X-MailThisForMe-Request-Key", requestKey)
|
|
return c.do(req, out)
|
|
}
|
|
|
|
func (c *apiClient) do(req *http.Request, out any) error {
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
data, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
|
return fmt.Errorf("%s: %s", resp.Status, formatAPIError(data))
|
|
}
|
|
if out == nil || len(data) == 0 {
|
|
return nil
|
|
}
|
|
if err := json.Unmarshal(data, out); err != nil {
|
|
return fmt.Errorf("could not parse JSON response: %w\n%s", err, string(data))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func addFilePart(writer *multipart.Writer, field, path string) error {
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer file.Close()
|
|
|
|
header := make(textproto.MIMEHeader)
|
|
header.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, escapeQuotes(field), escapeQuotes(filepath.Base(path))))
|
|
header.Set("Content-Type", "application/pdf")
|
|
part, err := writer.CreatePart(header)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = io.Copy(part, file)
|
|
return err
|
|
}
|
|
|
|
func addAddressFields(writer *multipart.Writer, prefix string, addr Address) {
|
|
fields := map[string]string{
|
|
"name": addr.Name,
|
|
"company": addr.Company,
|
|
"address1": addr.Address1,
|
|
"address2": addr.Address2,
|
|
"city": addr.City,
|
|
"state": addr.State,
|
|
"postal_code": addr.PostalCode,
|
|
"country": addr.Country,
|
|
"phone": addr.Phone,
|
|
}
|
|
for key, value := range fields {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
continue
|
|
}
|
|
_ = writer.WriteField(prefix+"["+key+"]", value)
|
|
}
|
|
}
|
|
|
|
func validateRequiredAddress(addr Address, label string) error {
|
|
missing := []string{}
|
|
if strings.TrimSpace(addr.Name) == "" {
|
|
missing = append(missing, "name")
|
|
}
|
|
if strings.TrimSpace(addr.Address1) == "" {
|
|
missing = append(missing, "address1")
|
|
}
|
|
if strings.TrimSpace(addr.City) == "" {
|
|
missing = append(missing, "city")
|
|
}
|
|
if strings.TrimSpace(addr.State) == "" {
|
|
missing = append(missing, "state")
|
|
}
|
|
if strings.TrimSpace(addr.PostalCode) == "" {
|
|
missing = append(missing, "postal_code")
|
|
}
|
|
if strings.TrimSpace(addr.Country) == "" {
|
|
missing = append(missing, "country")
|
|
}
|
|
if len(missing) > 0 {
|
|
return fmt.Errorf("%s address is missing: %s", label, strings.Join(missing, ", "))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func estimatePDFPageCount(path string) (int, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if len(data) < 5 || string(data[:5]) != "%PDF-" {
|
|
return 0, errNotPDF
|
|
}
|
|
|
|
re := regexp.MustCompile(`/Type\s*/Page\b`)
|
|
matches := re.FindAll(data, -1)
|
|
if len(matches) == 0 {
|
|
return 0, errors.New("could not find page objects")
|
|
}
|
|
if len(matches) > 10 {
|
|
return len(matches), fmt.Errorf("estimated %d pages; MailThisForMe allows up to 10", len(matches))
|
|
}
|
|
return len(matches), nil
|
|
}
|
|
|
|
func analyzePDF(path string) (pdfAnalysis, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return pdfAnalysis{}, err
|
|
}
|
|
if len(data) < 5 || string(data[:5]) != "%PDF-" {
|
|
return pdfAnalysis{}, errNotPDF
|
|
}
|
|
|
|
analysis := pdfAnalysis{}
|
|
pageRe := regexp.MustCompile(`/Type\s*/Page\b`)
|
|
analysis.PageCount = len(pageRe.FindAll(data, -1))
|
|
if analysis.PageCount == 0 {
|
|
analysis.Warnings = append(analysis.Warnings, "Could not estimate PDF page count.")
|
|
}
|
|
if analysis.PageCount > 10 {
|
|
analysis.Issues = append(analysis.Issues, fmt.Sprintf("PDF has %d pages; MailThisForMe allows up to 10.", analysis.PageCount))
|
|
}
|
|
|
|
mediaBoxRe := regexp.MustCompile(`/MediaBox\s*\[\s*(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)\s*\]`)
|
|
boxes := mediaBoxRe.FindAllSubmatch(data, -1)
|
|
if len(boxes) == 0 {
|
|
analysis.Warnings = append(analysis.Warnings, "Could not find PDF MediaBox dimensions for local Letter-size validation.")
|
|
return analysis, nil
|
|
}
|
|
|
|
dimensionsToCheck := len(boxes)
|
|
if analysis.PageCount > 0 && dimensionsToCheck > analysis.PageCount {
|
|
dimensionsToCheck = analysis.PageCount
|
|
}
|
|
if analysis.PageCount > 0 && len(boxes) < analysis.PageCount && len(boxes) != 1 {
|
|
analysis.Warnings = append(analysis.Warnings, fmt.Sprintf("Found %d MediaBox entries for %d pages; local page-size validation may be incomplete.", len(boxes), analysis.PageCount))
|
|
}
|
|
if analysis.PageCount > 1 && len(boxes) == 1 {
|
|
dimensionsToCheck = analysis.PageCount
|
|
}
|
|
|
|
for i := 0; i < dimensionsToCheck; i++ {
|
|
box := boxes[0]
|
|
if len(boxes) > 1 {
|
|
box = boxes[i]
|
|
}
|
|
x1, _ := strconv.ParseFloat(string(box[1]), 64)
|
|
y1, _ := strconv.ParseFloat(string(box[2]), 64)
|
|
x2, _ := strconv.ParseFloat(string(box[3]), 64)
|
|
y2, _ := strconv.ParseFloat(string(box[4]), 64)
|
|
width := absFloat(x2 - x1)
|
|
height := absFloat(y2 - y1)
|
|
dimension := pageDimension{
|
|
Page: i + 1,
|
|
Width: width,
|
|
Height: height,
|
|
IsLetter: isLetterSize(width, height),
|
|
}
|
|
analysis.Dimensions = append(analysis.Dimensions, dimension)
|
|
if !dimension.IsLetter {
|
|
analysis.Issues = append(analysis.Issues, fmt.Sprintf("Page %d is %s x %s points; expected US Letter 612 x 792 points.", dimension.Page, formatPoints(width), formatPoints(height)))
|
|
}
|
|
}
|
|
|
|
return analysis, nil
|
|
}
|
|
|
|
func isLetterSize(width, height float64) bool {
|
|
const tolerance = 2.0
|
|
portrait := absFloat(width-612) <= tolerance && absFloat(height-792) <= tolerance
|
|
landscape := absFloat(width-792) <= tolerance && absFloat(height-612) <= tolerance
|
|
return portrait || landscape
|
|
}
|
|
|
|
func absFloat(value float64) float64 {
|
|
if value < 0 {
|
|
return -value
|
|
}
|
|
return value
|
|
}
|
|
|
|
func formatPoints(value float64) string {
|
|
formatted := strconv.FormatFloat(value, 'f', 2, 64)
|
|
formatted = strings.TrimRight(formatted, "0")
|
|
return strings.TrimRight(formatted, ".")
|
|
}
|
|
|
|
func loadDotEnv(path string) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return
|
|
}
|
|
lines := strings.Split(string(data), "\n")
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "#") || !strings.Contains(line, "=") {
|
|
continue
|
|
}
|
|
key, value, _ := strings.Cut(line, "=")
|
|
key = strings.TrimSpace(key)
|
|
value = strings.TrimSpace(value)
|
|
value = strings.Trim(value, `"'`)
|
|
if key != "" && os.Getenv(key) == "" {
|
|
_ = os.Setenv(key, value)
|
|
}
|
|
}
|
|
}
|
|
|
|
func firstEnv(keys ...string) string {
|
|
for _, key := range keys {
|
|
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func expandPath(path string) string {
|
|
path = strings.TrimSpace(path)
|
|
if path == "" {
|
|
return path
|
|
}
|
|
if path == "~" {
|
|
if home, err := os.UserHomeDir(); err == nil {
|
|
return home
|
|
}
|
|
}
|
|
if strings.HasPrefix(path, "~/") {
|
|
if home, err := os.UserHomeDir(); err == nil {
|
|
return filepath.Join(home, path[2:])
|
|
}
|
|
}
|
|
return path
|
|
}
|
|
|
|
func formatAPIObject(v any) string {
|
|
data, err := json.MarshalIndent(v, "", " ")
|
|
if err != nil {
|
|
return fmt.Sprint(v)
|
|
}
|
|
return string(data)
|
|
}
|
|
|
|
func formatAPIError(data []byte) string {
|
|
var decoded any
|
|
if err := json.Unmarshal(data, &decoded); err == nil {
|
|
return formatAPIObject(decoded)
|
|
}
|
|
text := strings.TrimSpace(string(data))
|
|
if text == "" {
|
|
return "empty response body"
|
|
}
|
|
return text
|
|
}
|
|
|
|
func maskSecret(secret string) string {
|
|
secret = strings.TrimSpace(secret)
|
|
if secret == "" {
|
|
return "missing"
|
|
}
|
|
if len(secret) <= 10 {
|
|
return strings.Repeat("*", len(secret))
|
|
}
|
|
return secret[:8] + "..." + secret[len(secret)-4:]
|
|
}
|
|
|
|
func addressOneLine(addr Address) string {
|
|
parts := []string{}
|
|
for _, part := range []string{addr.Name, addr.Company, addr.Address1, addr.Address2, addr.City, addr.State, addr.PostalCode, addr.Country} {
|
|
part = strings.TrimSpace(part)
|
|
if part != "" {
|
|
parts = append(parts, part)
|
|
}
|
|
}
|
|
if len(parts) == 0 {
|
|
return "not set"
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
|
|
func pageSummary(pages int) string {
|
|
if pages <= 0 {
|
|
return "unknown"
|
|
}
|
|
if pages == 1 {
|
|
return "1 page"
|
|
}
|
|
return fmt.Sprintf("%d pages", pages)
|
|
}
|
|
|
|
func humanBytes(n int64) string {
|
|
const unit = 1024
|
|
if n < unit {
|
|
return fmt.Sprintf("%d B", n)
|
|
}
|
|
div, exp := int64(unit), 0
|
|
for size := n / unit; size >= unit; size /= unit {
|
|
div *= unit
|
|
exp++
|
|
}
|
|
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
|
|
}
|
|
|
|
func defaultString(value, fallback string) string {
|
|
if strings.TrimSpace(value) == "" {
|
|
return fallback
|
|
}
|
|
return value
|
|
}
|
|
|
|
func confirm(a *appState, label string) bool {
|
|
answer := strings.ToLower(strings.TrimSpace(a.prompt(label + " [y/N]")))
|
|
return answer == "y" || answer == "yes"
|
|
}
|
|
|
|
func newRequestKey() string {
|
|
var buf [8]byte
|
|
if _, err := rand.Read(buf[:]); err != nil {
|
|
return fmt.Sprintf("mtfm-tui-%d", time.Now().UnixNano())
|
|
}
|
|
return "mtfm-tui-" + time.Now().UTC().Format("20060102T150405Z") + "-" + hex.EncodeToString(buf[:])
|
|
}
|
|
|
|
func escapeQuotes(value string) string {
|
|
return strings.ReplaceAll(value, `"`, `\"`)
|
|
}
|
|
|
|
func clearScreen() {
|
|
fmt.Print("\033[H\033[2J")
|
|
}
|