Files
mailthisforme.com-client/main.go
T

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")
}