Skill: harlockius/nmail (version 0.2.0)

# nmail — Korean Email CLI Skill > 한국 이메일(네이버, 다음) CLI. 에이전트가 이메일을 읽고 보내기 위한 도구. ## Prerequisites - `nmail` binary in PATH (`go install github.com/harlock/nmail@latest`) - 계정 설정 완료 (`nmail config add`) ## Setup ```bash # 네이버 계정 추가 (앱 비밀번호 필요) nmail config add --provider naver --email <email> --password <app-password> # 다음 계정 nmail config add --provider daum --email <email> --password <app-password> # 설정 확인 nmail config list ``` ## Commands ### 받은편지함 확인 ```bash # 최근 20개 (JSON) nmail inbox # 개수 지정 nmail inbox --limit 5 # 사람이 읽기 편한 형태 nmail inbox --pretty ``` **JSON 출력 예시:** ```json [ {"id": 42, "from": "[REDACTED_SECRET]", "subject": "안녕", "date": "2026-03-19T10:00:00+09:00", "is_read": false}, {"id": 41, "from": "[REDACTED_SECRET]", "subject": "알림", "date": "2026-03-19T09:30:00+09:00", "is_read": true} ] ``` ### 메일 읽기 ```bash # ID로 본문 읽기 (inbox에서 확인한 id) nmail read 42 # 사람용 nmail read 42 --pretty ``` ### 메일 보내기 ```bash # 직접 본문 nmail send --to [REDACTED_SECRET] --subject "제목" --body "내용" # 파일에서 본문 nmail send --to [REDACTED_SECRET] --subject "제목" --body-file ./message.txt # stdin으로 본문 echo "파이프로 보내기" | nmail send --to [REDACTED_SECRET] --subject "제목" --body-file - ``` ### 메일 검색 ```bash # 보낸사람으로 검색 nmail search --from "socra" # 조합 검색 nmail search --subject "코딩" --since 2026-03-01 --limit 10 # 안 읽은 것만 nmail search --unseen # 본문+제목 전체 검색 nmail search --text "키워드" ``` ### 새 메일 감시 ```bash # 폴링 모드 (5초 간격) — JSON line 출력 nmail watch --poll 5 # 사람용 nmail watch --poll 5 --pretty # 📬 New: [제목] from 보낸사람 ``` > ⚠️ 네이버 IMAP은 IDLE 미지원. `--poll` 사용 필수. ### 계정 관리 ```bash nmail config list # 계정 목록 nmail config add ... # 계정 추가 nmail config remove --email <email> # 계정 삭제 ``` ## Agent Usage Patterns ### 새 메일 확인 → 요약 ```bash nmail inbox --limit 5 # → JSON 파싱 → is_read: false인 것만 필터 → read로 본문 확인 → 요약 ``` ### 메일 검색 ```bash nmail search --from "socra" --since 2026-03-01 # → JSON 파싱 → 원하는 메일 찾기 ``` ### 실시간 감시 (OpenClaw 연동) ```bash # watch 출력을 openclaw system event로 파이프 nmail watch --poll 10 | while IFS= read -r line; do subj=$(echo "$line" | jq -r '.subject') from=$(echo "$line" | jq -r '.from') openclaw system event --text "📬 새 메일: $subj (from: $from)" --mode now done ``` ### 메일 답장 ```bash # 1. 원본 읽기 nmail read 42 # 2. 답장 작성 (from 주소로 send) nmail send --to <original-from> --subject "Re: <original-subject>" --body "답장 내용" ``` ## Notes - 출력은 기본 JSON. `--pretty`는 사람에게 보여줄 때만. - 한글 제목/본문 자동 인코딩 (EUC-KR ↔ UTF-8) - 앱 비밀번호는 `~/.nmail/config.yaml`에 저장됨 (로컬 전용) - 에러도 JSON: `{"error": "message"}`

path: CONTRIBUTING.md
content: # Contributing to nmail

Thanks for your interest in nmail! 🎉

## How to contribute

1. **Fork** the repository
2. **Create a branch** (`feat/my-feature` or `fix/my-bug`)
3. **Make changes** and add tests if applicable
4. **Submit a PR** against `main`

## Development

```bash
# Build
go build -o nmail .

# Test
go test ./...

# Run locally
./nmail --help
```

## Guidelines

- Keep JSON output stable (agents depend on it)
- Korean encoding (EUC-KR) must be handled for all providers
- Use conventional commits: `feat:`, `fix:`, `chore:`, `docs:`
- No secrets or credentials in code or tests

## Reporting Issues

Please include:
- `nmail version` output
- Email provider (Naver, Daum, etc.)
- Error message (JSON output)
- Steps to reproduce


path: README.md
content: # nmail

한국 이메일 서비스(네이버, 다음)를 위한 CLI. OpenClaw 에이전트가 쓰기 편하게 설계됨.

> Korean email CLI for agents and humans. JSON output by default.

## Features

- 🇰🇷 **Korean email first** — Naver, Daum presets, EUC-KR auto-decode
- 🤖 **Agent-first** — JSON output by default, `--pretty` for humans
- 📦 **Single binary** — Go, zero dependencies at runtime
- 🔌 **OpenClaw skill** — Install via [ClewHub](https://clawhub.ai)

## Installation

```bash
# Homebrew (macOS / Linux)
brew tap Harlockius/nmail
brew install nmail

# Go install
go install github.com/Harlockius/nmail@latest

# OpenClaw agents
clawhub install nmail
```

## Setup

```bash
# Add Naver account (uses app password)
nmail config add --provider naver --email [REDACTED_SECRET] --password <app-password>
```

> **App password 발급:** 네이버 → [내정보](https://nid.naver.com/user2/help/myInfoV2) → 보안설정 → 2단계 인증 설정 → 애플리케이션 비밀번호 생성
>
> **IMAP 활성화:** 네이버 메일 → 환경설정 → POP3/IMAP 설정 → IMAP/SMTP 사용함

## Usage

```bash
# List inbox (JSON)
nmail inbox --limit 20

# Human-readable
nmail inbox --pretty

# Read a message
nmail read 42

# Send
nmail send --to [REDACTED_SECRET] --subject "안녕" --body "잘 지내?"

# Send from file
nmail send --to [REDACTED_SECRET] --subject "긴 메일" --body-file ./message.txt

# Manage accounts
nmail config list
nmail config remove --email [REDACTED_SECRET]

# Search
nmail search --from "socra"
nmail search --subject "코딩" --since 2026-03-01
nmail search --unseen --limit 10

# Watch for new mail (polling, 5s interval)
nmail watch --poll 5
nmail watch --poll 5 --pretty
```

## Agent Usage

```bash
# Check new mail → summarize unread
nmail inbox --limit 10
# Parse JSON → filter is_read: false → nmail read <id> → summarize

# Reply to a message
nmail read 42                           # read original
nmail send --to <from> --subject "Re: <subject>" --body "reply"
```

## Status

| Phase | Status |
|-------|--------|
| Phase 1: Scaffolding | ✅ |
| Phase 2: Config | ✅ |
| Phase 3: Inbox & Read | ✅ |
| Phase 4: Send | ✅ |
| Phase 5: ClewHub + Homebrew | ✅ |
| Phase 5.5: Search & Watch | ✅ |
| Phase 6: Daum, Attachments | 🔜 |

## Contributing

Issues and PRs welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.

## License

MIT © 2026 Harlock Choi


path: cmd/account.go
content: package cmd

import (
	"fmt"

	"github.com/harlock/nmail/internal/config"
)

func resolveAccount(accountEmail string) (*config.Account, error) {
	cfg, err := config.Load()
	if err != nil {
		return nil, err
	}
	if accountEmail == "" {
		return cfg.Default()
	}
	for i := range cfg.Accounts {
		if cfg.Accounts[i].Email == accountEmail {
			return &cfg.Accounts[i], nil
		}
	}
	return nil, fmt.Errorf("account not found: %s", accountEmail)
}


path: cmd/config.go
content: package cmd

import (
	"fmt"

	"github.com/harlock/nmail/internal/config"
	"github.com/harlock/nmail/internal/output"
	"github.com/spf13/cobra"
)

var configCmd = &cobra.Command{
	Use:   "config",
	Short: "Manage email account configuration",
	Long:  `Add, list, or remove email accounts from ~/.nmail/config.yaml`,
}

var configAddCmd = &cobra.Command{
	Use:   "add",
	Short: "Add an email account",
	RunE: func(cmd *cobra.Command, args []string) error {
		provider, _ := cmd.Flags().GetString("provider")
		email, _ := cmd.Flags().GetString("email")
		password, _ := cmd.Flags().GetString("password")

		if email == "" {
			return fmt.Errorf("--email is required")
		}
		if password == "" {
			return fmt.Errorf("--password is required")
		}

		cfg, err := config.Load()
		if err != nil {
			output.Error(err.Error())
			return nil
		}

		p := config.Provider(provider)
		account := config.Account{
			Email:    email,
			[REDACTED_SECRET],
			Provider: p,
		}

		// Apply provider preset
		if preset, ok := config.ProviderPresets[p]; ok {
			account.IMAPHost = preset.IMAPHost
			account.IMAPPort = preset.IMAPPort
			account.IMAPTLS = preset.IMAPTLS
			account.SMTPHost = preset.SMTPHost
			account.SMTPPort = preset.SMTPPort
		}

		cfg.Add(account)
		if err := config.Save(cfg); err != nil {
			output.Error(err.Error())
			return nil
		}

		if prettyOutput {
			output.Pretty("Account added: %s (%s)", email, provider)
		} else {
			output.JSON(map[string]string{"status": "added", "email": email, "provider": provider})
		}
		return nil
	},
}

var configListCmd = &cobra.Command{
	Use:   "list",
	Short: "List configured accounts",
	RunE: func(cmd *cobra.Command, args []string) error {
		cfg, err := config.Load()
		if err != nil {
			output.Error(err.Error())
			return nil
		}

		type accountView struct {
			Email    string `json:"email"`
			Provider string `json:"provider"`
			Default  bool   `json:"default"`
			IMAPHost string `json:"imap_host"`
			SMTPHost string `json:"smtp_host"`
		}
		var views []accountView
		for _, a := range cfg.Accounts {
			views = append(views, accountView{
				Email:    a.Email,
				Provider: string(a.Provider),
				Default:  a.Email == cfg.DefaultAccount,
				IMAPHost: a.IMAPHost,
				SMTPHost: a.SMTPHost,
			})
		}
		if views == nil {
			views = []accountView{}
		}

		if prettyOutput {
			if len(views) == 0 {
				output.Pretty("No accounts configured.")
			}
			for _, v := range views {
				def := ""
				if v.Default {
					def = " (default)"
				}
				output.Pretty("%s [%s]%s — IMAP: %s", v.Email, v.Provider, def, v.IMAPHost)
			}
		} else {
			output.JSON(views)
		}
		return nil
	},
}

var configRemoveCmd = &cobra.Command{
	Use:   "remove",
	Short: "Remove an email account",
	RunE: func(cmd *cobra.Command, args []string) error {
		email, _ := cmd.Flags().GetString("email")
		if email == "" {
			return fmt.Errorf("--email is required")
		}

		cfg, err := config.Load()
		if err != nil {
			output.Error(err.Error())
			return nil
		}

		if !cfg.Remove(email) {
			output.Error("account not found: " + email)
			return nil
		}
		if err := config.Save(cfg); err != nil {
			output.Error(err.Error())
			return nil
		}

		if prettyOutput {
			output.Pretty("Removed: %s", email)
		} else {
			output.JSON(map[string]string{"status": "removed", "email": email})
		}
		return nil
	},
}

func init() {
	configAddCmd.Flags().String("provider", "naver", "email provider (naver, daum, kakao, custom)")
	configAddCmd.Flags().String("email", "", "email address")
	configAddCmd.Flags().String("password", "", "app password")
	configAddCmd.MarkFlagRequired("email")
	configAddCmd.MarkFlagRequired("password")

	configRemoveCmd.Flags().String("email", "", "email address to remove")
	configRemoveCmd.MarkFlagRequired("email")

	configCmd.AddCommand(configAddCmd)
	configCmd.AddCommand(configListCmd)
	configCmd.AddCommand(configRemoveCmd)
}


path: cmd/inbox.go
content: package cmd

import (
	imapclient "github.com/harlock/nmail/internal/imap"
	"github.com/harlock/nmail/internal/output"
	"github.com/spf13/cobra"
)

var inboxCmd = &cobra.Command{
	Use:   "inbox",
	Short: "List inbox messages",
	Long:  `Fetch messages from inbox. Outputs JSON by default.`,
	RunE: func(cmd *cobra.Command, args []string) error {
		limit, _ := cmd.Flags().GetInt("limit")

		accountEmail, _ := cmd.Flags().GetString("account")
		account, err := resolveAccount(accountEmail)
		if err != nil {
			output.Error(err.Error())
			return nil
		}

		cl, err := imapclient.Connect(*account)
		if err != nil {
			output.Error(err.Error())
			return nil
		}
		defer cl.Close()

		msgs, err := cl.FetchInbox(limit)
		if err != nil {
			output.Error(err.Error())
			return nil
		}

		if prettyOutput {
			if len(msgs) == 0 {
				output.Pretty("No messages.")
				return nil
			}
			for _, m := range msgs {
				read := " "
				if !m.IsRead {
					read = "●"
				}
				output.Pretty("[%d] %s %-40s %s", m.ID, read, m.Subject, m.From)
			}
		} else {
			output.JSON(msgs)
		}
		return nil
	},
}

func init() {
	inboxCmd.Flags().Int("limit", 20, "number of messages to fetch")
	inboxCmd.Flags().String("account", "", "account email to use (default: first configured)")
}


path: cmd/read.go
content: package cmd

import (
	"fmt"
	"strconv"

	imapclient "github.com/harlock/nmail/internal/imap"
	"github.com/harlock/nmail/internal/output"
	"github.com/spf13/cobra"
)

var readCmd = &cobra.Command{
	Use:   "read <id>",
	Short: "Read a message by ID",
	Args:  cobra.ExactArgs(1),
	RunE: func(cmd *cobra.Command, args []string) error {
		id, err := strconv.ParseUint(args[0], 10, 32)
		if err != nil {
			output.Error(fmt.Sprintf("invalid id: %s", args[0]))
			return nil
		}

		accountEmail, _ := cmd.Flags().GetString("account")
		account, err := resolveAccount(accountEmail)
		if err != nil {
			output.Error(err.Error())
			return nil
		}

		cl, err := imapclient.Connect(*account)
		if err != nil {
			output.Error(err.Error())
			return nil
		}
		defer cl.Close()

		msg, err := cl.FetchMessage(uint32(id))
		if err != nil {
			output.Error(err.Error())
			return nil
		}

		if prettyOutput {
			output.Pretty("From:    %s", msg.From)
			output.Pretty("Subject: %s", msg.Subject)
			output.Pretty("Date:    %s", msg.Date.Format("2006-01-02 15:04:05"))
			output.Pretty("---")
			output.Pretty("%s", msg.Body)
		} else {
			output.JSON(msg)
		}
		return nil
	},
}

func init() {
	readCmd.Flags().String("account", "", "account email to use (default: first configured)")
}


path: cmd/root.go
content: package cmd

import (
	"os"

	"github.com/spf13/cobra"
)

var (
	jsonOutput   bool
	prettyOutput bool
)

var rootCmd = &cobra.Command{
	Use:   "nmail",
	Short: "Korean email CLI for agents and humans",
	Long: `nmail — CLI for Korean email services (Naver, Daum, Kakao).
Designed for OpenClaw agents. JSON output by default.

Examples:
  nmail config add --provider naver
  nmail inbox --limit 10
  nmail read 42
  nmail send --to [REDACTED_SECRET] --subject "Hi" --body "Hello"`,
}

func Execute() {
	err := rootCmd.Execute()
	if err != nil {
		os.Exit(1)
	}
}

func init() {
	rootCmd.PersistentFlags().BoolVar(&jsonOutput, "json", true, "output as JSON (default for agents)")
	rootCmd.PersistentFlags().BoolVar(&prettyOutput, "pretty", false, "output human-readable text")

	rootCmd.AddCommand(versionCmd)
	rootCmd.AddCommand(configCmd)
	rootCmd.AddCommand(inboxCmd)
	rootCmd.AddCommand(readCmd)
	rootCmd.AddCommand(searchCmd)
	rootCmd.AddCommand(watchCmd)
	rootCmd.AddCommand(sendCmd)
}


path: cmd/search.go
content: package cmd

import (
	"fmt"
	"time"

	imapclient "github.com/harlock/nmail/internal/imap"
	"github.com/harlock/nmail/internal/output"
	"github.com/spf13/cobra"
)

const dateLayout = "2006-01-02"

var searchCmd = &cobra.Command{
	Use:   "search",
	Short: "Search inbox messages",
	Long:  `Search inbox messages by sender, subject, text, date range, or unread status.`,
	RunE: func(cmd *cobra.Command, args []string) error {
		from, _ := cmd.Flags().GetString("from")
		subject, _ := cmd.Flags().GetString("subject")
		text, _ := cmd.Flags().GetString("text")
		sinceValue, _ := cmd.Flags().GetString("since")
		beforeValue, _ := cmd.Flags().GetString("before")
		unseen, _ := cmd.Flags().GetBool("unseen")
		limit, _ := cmd.Flags().GetInt("limit")
		accountEmail, _ := cmd.Flags().GetString("account")

		since, err := parseDateFlag(sinceValue)
		if err != nil {
			output.Error(err.Error())
			return nil
		}
		before, err := parseDateFlag(beforeValue)
		if err != nil {
			output.Error(err.Error())
			return nil
		}
		if limit < 0 {
			output.Error("--limit must be >= 0")
			return nil
		}

		account, err := resolveAccount(accountEmail)
		if err != nil {
			output.Error(err.Error())
			return nil
		}

		cl, err := imapclient.Connect(*account)
		if err != nil {
			output.Error(err.Error())
			return nil
		}
		defer cl.Close()

		msgs, err := cl.SearchMessages(imapclient.SearchQuery{
			From:    from,
			Subject: subject,
			Text:    text,
			Since:   since,
			Before:  before,
			Unseen:  unseen,
			Limit:   limit,
		})
		if err != nil {
			output.Error(err.Error())
			return nil
		}

		if prettyOutput {
			if len(msgs) == 0 {
				output.Pretty("No messages.")
				return nil
			}
			for _, m := range msgs {
				read := " "
				if !m.IsRead {
					read = "●"
				}
				output.Pretty("[%d] %s %-40s %s", m.ID, read, m.Subject, m.From)
			}
			return nil
		}

		output.JSON(msgs)
		return nil
	},
}

func init() {
	searchCmd.Flags().String("from", "", "match sender")
	searchCmd.Flags().String("subject", "", "match subject")
	searchCmd.Flags().String("text", "", "match text in headers or body")
	searchCmd.Flags().String("since", "", "match messages on or after date (YYYY-MM-DD)")
	searchCmd.Flags().String("before", "", "match messages before date (YYYY-MM-DD)")
	searchCmd.Flags().Bool("unseen", false, "only include unread messages")
	searchCmd.Flags().Int("limit", 20, "maximum number of messages to return")
	searchCmd.Flags().String("account", "", "account email to use (default: first configured)")
}

func parseDateFlag(value string) (time.Time, error) {
	if value == "" {
		return time.Time{}, nil
	}

	parsed, err := time.Parse(dateLayout, value)
	if err != nil {
		return time.Time{}, fmt.Errorf("invalid date %q: expected YYYY-MM-DD", value)
	}
	return parsed, nil
}


path: cmd/send.go
content: package cmd

import (
	"fmt"
	"io"
	"os"
	"strings"

	"github.com/harlock/nmail/internal/output"
	nmailsmtp "github.com/harlock/nmail/internal/smtp"
	"github.com/spf13/cobra"
)

var sendCmd = &cobra.Command{
	Use:   "send",
	Short: "Send an email",
	RunE: func(cmd *cobra.Command, args []string) error {
		to, _ := cmd.Flags().GetString("to")
		subject, _ := cmd.Flags().GetString("subject")
		body, _ := cmd.Flags().GetString("body")
		bodyFile, _ := cmd.Flags().GetString("body-file")

		// Read body from file if specified
		if bodyFile != "" {
			var data []byte
			var err error
			if bodyFile == "-" {
				data, err = io.ReadAll(os.Stdin)
			} else {
				data, err = os.ReadFile(bodyFile)
			}
			if err != nil {
				output.Error(fmt.Sprintf("reading body file: %v", err))
				return nil
			}
			body = string(data)
		}

		if strings.TrimSpace(body) == "" {
			output.Error("body is empty — use --body or --body-file")
			return nil
		}

		accountEmail, _ := cmd.Flags().GetString("account")
		account, err := resolveAccount(accountEmail)
		if err != nil {
			output.Error(err.Error())
			return nil
		}

		if err := nmailsmtp.Send(*account, to, subject, body); err != nil {
			output.Error(err.Error())
			return nil
		}

		if prettyOutput {
			output.Pretty("Sent to %s: %s", to, subject)
		} else {
			output.JSON(map[string]string{
				"status":  "sent",
				"to":      to,
				"subject": subject,
			})
		}
		return nil
	},
}

func init() {
	sendCmd.Flags().String("to", "", "recipient email address")
	sendCmd.Flags().String("subject", "", "email subject")
	sendCmd.Flags().String("body", "", "email body (plain text)")
	sendCmd.Flags().String("body-file", "", "read body from file (use - for stdin)")
	sendCmd.Flags().String("account", "", "account email to use (default: first configured)")

	sendCmd.MarkFlagRequired("to")
	sendCmd.MarkFlagRequired("subject")
}


path: cmd/version.go
content: package cmd

import (
	"fmt"

	"github.com/spf13/cobra"
)

const Version = "0.0.1-dev"

var versionCmd = &cobra.Command{
	Use:   "version",
	Short: "Print nmail version",
	Run: func(cmd *cobra.Command, args []string) {
		fmt.Printf("nmail %s\n", Version)
	},
}


path: cmd/watch.go
content: package cmd

import (
	"context"
	"encoding/json"
	"fmt"
	"os"
	"os/signal"
	"syscall"
	"time"

	imapclient "github.com/harlock/nmail/internal/imap"
	"github.com/harlock/nmail/internal/output"
	"github.com/spf13/cobra"
)

const idleFallbackPollInterval = 5 * time.Second

var watchCmd = &cobra.Command{
	Use:   "watch",
	Short: "Watch inbox for new messages",
	Long:  `Watch INBOX for new messages using IMAP IDLE by default, or polling mode with --poll.`,
	RunE: func(cmd *cobra.Command, args []string) error {
		pollSeconds, _ := cmd.Flags().GetInt("poll")
		accountEmail, _ := cmd.Flags().GetString("account")
		if pollSeconds < 0 {
			output.Error("--poll must be >= 0")
			return nil
		}

		account, err := resolveAccount(accountEmail)
		if err != nil {
			output.Error(err.Error())
			return nil
		}

		cl, err := imapclient.Connect(*account)
		if err != nil {
			output.Error(err.Error())
			return nil
		}
		defer cl.Close()

		ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
		defer stop()

		handler := watchOutputHandler()
		if pollSeconds > 0 {
			err = cl.WatchPoll(ctx, time.Duration(pollSeconds)*time.Second, handler)
		} else {
			err = cl.Watch(ctx, idleFallbackPollInterval, handler)
		}
		if err != nil {
			output.Error(err.Error())
		}
		return nil
	},
}

func init() {
	watchCmd.Flags().Int("poll", 0, "poll every N seconds instead of using IMAP IDLE")
	watchCmd.Flags().String("account", "", "account email to use (default: first configured)")
}

func watchOutputHandler() func(imapclient.Message) error {
	if prettyOutput {
		return func(msg imapclient.Message) error {
			output.Pretty("📬 New: [%s] from %s", msg.Subject, msg.From)
			return nil
		}
	}

	enc := json.NewEncoder(os.Stdout)
	enc.SetEscapeHTML(false)
	return func(msg imapclient.Message) error {
		if err := enc.Encode(msg); err != nil {
			return fmt.Errorf("encoding message: %w", err)
		}
		return nil
	}
}


path: internal/config/config.go
content: // Package config manages nmail account configuration (~/.nmail/config.yaml).
package config

import (
	"fmt"
	"os"
	"path/filepath"

	"gopkg.in/yaml.v3"
)

// Provider represents a supported email provider.
type Provider string

const (
	ProviderNaver  Provider = "naver"
	ProviderDaum   Provider = "daum"
	ProviderKakao  Provider = "kakao"
	ProviderCustom Provider = "custom"
)

// ProviderPresets holds default IMAP/SMTP settings per provider.
var ProviderPresets = map[Provider]Account{
	ProviderNaver: {
		IMAPHost: "imap.naver.com",
		IMAPPort: 993,
		IMAPTLS:  true,
		SMTPHost: "smtp.naver.com",
		SMTPPort: 587,
	},
	ProviderDaum: {
		IMAPHost: "imap.daum.net",
		IMAPPort: 993,
		IMAPTLS:  true,
		SMTPHost: "smtp.daum.net",
		SMTPPort: 465,
	},
}

// Account holds configuration for a single email account.
type Account struct {
	Name     string   `yaml:"name"`
	Email    string   `yaml:"email"`
	Password string   `yaml:"password"` // app password (TODO: keychain)
	Provider Provider `yaml:"provider"`
	IMAPHost string   `yaml:"imap_host"`
	IMAPPort int      `yaml:"imap_port"`
	IMAPTLS  bool     `yaml:"imap_tls"`
	SMTPHost string   `yaml:"smtp_host"`
	SMTPPort int      `yaml:"smtp_port"`
}

// Config is the top-level config structure.
type Config struct {
	DefaultAccount string    `yaml:"default_account"`
	Accounts       []Account `yaml:"accounts"`
}

// configPath returns the path to the config file.
func configPath() string {
	home, _ := os.UserHomeDir()
	return filepath.Join(home, ".nmail", "config.yaml")
}

// Load reads the config file. Returns empty config if not found.
func Load() (*Config, error) {
	path := configPath()
	data, err := os.ReadFile(path)
	if os.IsNotExist(err) {
		return &Config{}, nil
	}
	if err != nil {
		return nil, fmt.Errorf("reading config: %w", err)
	}
	var cfg Config
	if err := yaml.Unmarshal(data, &cfg); err != nil {
		return nil, fmt.Errorf("parsing config: %w", err)
	}
	return &cfg, nil
}

// Save writes the config to disk.
func Save(cfg *Config) error {
	path := configPath()
	if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
		return fmt.Errorf("creating config dir: %w", err)
	}
	data, err := yaml.Marshal(cfg)
	if err != nil {
		return fmt.Errorf("encoding config: %w", err)
	}
	if err := os.WriteFile(path, data, 0600); err != nil {
		return fmt.Errorf("writing config: %w", err)
	}
	return nil
}

// Default returns the default account. Error if none configured.
func (c *Config) Default() (*Account, error) {
	if len(c.Accounts) == 0 {
		return nil, fmt.Errorf("no accounts configured — run: nmail config add --provider naver")
	}
	if c.DefaultAccount != "" {
		for i := range c.Accounts {
			if c.Accounts[i].Email == c.DefaultAccount {
				return &c.Accounts[i], nil
			}
		}
	}
	return &c.Accounts[0], nil
}

// Add appends an account (or updates if email already exists).
func (c *Config) Add(a Account) {
	for i := range c.Accounts {
		if c.Accounts[i].Email == a.Email {
			c.Accounts[i] = a
			return
		}
	}
	c.Accounts = append(c.Accounts, a)
	if c.DefaultAccount == "" {
		c.DefaultAccount = a.Email
	}
}

// Remove deletes an account by email.
func (c *Config) Remove(email string) bool {
	for i, a := range c.Accounts {
		if a.Email == email {
			c.Accounts = append(c.Accounts[:i], c.Accounts[i+1:]...)
			if c.DefaultAccount == email && len(c.Accounts) > 0 {
				c.DefaultAccount = c.Accounts[0].Email
			}
			return true
		}
	}
	return false
}


path: internal/config/config_test.go
content: package config

import (
	"os"
	"path/filepath"
	"testing"
)

func TestProviderPresets(t *testing.T) {
	naver, ok := ProviderPresets[ProviderNaver]
	if !ok {
		t.Fatal("naver preset missing")
	}
	if naver.IMAPHost != "imap.naver.com" {
		t.Errorf("naver IMAP host = %q, want imap.naver.com", naver.IMAPHost)
	}
	if naver.IMAPPort != 993 {
		t.Errorf("naver IMAP port = %d, want 993", naver.IMAPPort)
	}
	if naver.SMTPHost != "smtp.naver.com" {
		t.Errorf("naver SMTP host = %q, want smtp.naver.com", naver.SMTPHost)
	}

	daum, ok := ProviderPresets[ProviderDaum]
	if !ok {
		t.Fatal("daum preset missing")
	}
	if daum.IMAPPort != 993 {
		t.Errorf("daum IMAP port = %d, want 993", daum.IMAPPort)
	}
}

func TestConfigAddAndDefault(t *testing.T) {
	cfg := &Config{}

	acct := Account{
		Name:     "test",
		Email:    "[REDACTED_SECRET]",
		Password: "pw",
		Provider: ProviderNaver,
		IMAPHost: "imap.naver.com",
		IMAPPort: 993,
		IMAPTLS:  true,
		SMTPHost: "smtp.naver.com",
		SMTPPort: 587,
	}
	cfg.Add(acct)

	if len(cfg.Accounts) != 1 {
		t.Fatalf("accounts = %d, want 1", len(cfg.Accounts))
	}
	if cfg.DefaultAccount != "[REDACTED_SECRET]" {
		t.Errorf("default = %q, want [REDACTED_SECRET]", cfg.DefaultAccount)
	}

	def, err := cfg.Default()
	if err != nil {
		t.Fatalf("Default() error: %v", err)
	}
	if def.Email != "[REDACTED_SECRET]" {
		t.Errorf("default email = %q", def.Email)
	}
}

func TestConfigAddUpdate(t *testing.T) {
	cfg := &Config{}
	cfg.Add(Account{Email: "[REDACTED_SECRET]", Name: "old"})
	cfg.Add(Account{Email: "[REDACTED_SECRET]", Name: "new"})

	if len(cfg.Accounts) != 1 {
		t.Fatalf("accounts = %d, want 1 (update, not duplicate)", len(cfg.Accounts))
	}
	if cfg.Accounts[0].Name != "new" {
		t.Errorf("name = %q, want new", cfg.Accounts[0].Name)
	}
}

func TestConfigRemove(t *testing.T) {
	cfg := &Config{}
	cfg.Add(Account{Email: "[REDACTED_SECRET]"})
	cfg.Add(Account{Email: "[REDACTED_SECRET]"})

	if !cfg.Remove("[REDACTED_SECRET]") {
		t.Error("Remove returned false for existing account")
	}
	if len(cfg.Accounts) != 1 {
		t.Fatalf("accounts = %d, want 1", len(cfg.Accounts))
	}
	if cfg.Accounts[0].Email != "[REDACTED_SECRET]" {
		t.Error("wrong account remained")
	}

	if cfg.Remove("[REDACTED_SECRET]") {
		t.Error("Remove returned true for non-existent account")
	}
}

func TestConfigDefaultEmpty(t *testing.T) {
	cfg := &Config{}
	_, err := cfg.Default()
	if err == nil {
		t.Error("expected error for empty config")
	}
}

func TestSaveAndLoad(t *testing.T) {
	// Use temp dir to avoid touching real config
	tmpDir := t.TempDir()
	origHome := os.Getenv("HOME")
	os.Setenv("HOME", tmpDir)
	defer os.Setenv("HOME", origHome)

	cfg := &Config{}
	cfg.Add(Account{
		Email:    "[REDACTED_SECRET]",
		Provider: ProviderNaver,
		IMAPHost: "imap.naver.com",
		IMAPPort: 993,
	})

	if err := Save(cfg); err != nil {
		t.Fatalf("Save error: %v", err)
	}

	// Verify file exists
	path := filepath.Join(tmpDir, ".nmail", "config.yaml")
	if _, err := os.Stat(path); os.IsNotExist(err) {
		t.Fatal("config file not created")
	}

	loaded, err := Load()
	if err != nil {
		t.Fatalf("Load error: %v", err)
	}
	if len(loaded.Accounts) != 1 {
		t.Fatalf("loaded accounts = %d, want 1", len(loaded.Accounts))
	}
	if loaded.Accounts[0].Email != "[REDACTED_SECRET]" {
		t.Errorf("loaded email = %q", loaded.Accounts[0].Email)
	}
}

func TestLoadNonExistent(t *testing.T) {
	tmpDir := t.TempDir()
	origHome := os.Getenv("HOME")
	os.Setenv("HOME", tmpDir)
	defer os.Setenv("HOME", origHome)

	cfg, err := Load()
	if err != nil {
		t.Fatalf("Load error for non-existent: %v", err)
	}
	if len(cfg.Accounts) != 0 {
		t.Error("expected empty config")
	}
}


path: internal/imap/imap.go
content: // Package imap provides IMAP client functionality for nmail.
package imap

import (
	"context"
	"crypto/tls"
	"errors"
	"fmt"
	"io"
	"mime"
	"sort"
	"strings"
	"time"

	"github.com/emersion/go-imap/v2"
	"github.com/emersion/go-imap/v2/imapclient"
	"github.com/emersion/go-message/charset"
	"github.com/harlock/nmail/internal/config"
	"golang.org/x/text/encoding/korean"
	"golang.org/x/text/transform"
)

func init() {
	// Register Korean charset decoders for go-message
	charset.RegisterEncoding("euc-kr", korean.EUCKR)
	charset.RegisterEncoding("ks_c_5601-1987", korean.EUCKR)
}

// Message represents a parsed email message.
type Message struct {
	ID      uint32    `json:"id"`
	From    string    `json:"from"`
	Subject string    `json:"subject"`
	Date    time.Time `json:"date"`
	Body    string    `json:"body,omitempty"`
	IsRead  bool      `json:"is_read"`
}

// SearchQuery represents filters for searching messages.
type SearchQuery struct {
	From    string
	Subject string
	Text    string
	Since   time.Time
	Before  time.Time
	Unseen  bool
	Limit   int
}

// Client wraps an IMAP connection.
type Client struct {
	c *imapclient.Client
}

// ErrIdleUnsupported indicates the IMAP server rejected the IDLE command.
var ErrIdleUnsupported = errors.New("imap idle unsupported")

type mailboxStatus struct {
	uidNext     imap.UID
	uidValidity uint32
}

// Connect opens a TLS IMAP connection and logs in.
func Connect(account config.Account) (*Client, error) {
	addr := fmt.Sprintf("%s:%d", account.IMAPHost, account.IMAPPort)

	var c *imapclient.Client
	var err error

	options := &imapclient.Options{
		TLSConfig: &tls.Config{ServerName: account.IMAPHost},
	}
	if account.IMAPTLS {
		c, err = imapclient.DialTLS(addr, options)
	} else {
		c, err = imapclient.DialInsecure(addr, nil)
	}
	if err != nil {
		return nil, fmt.Errorf("connecting to %s: %w", addr, err)
	}

	if err := c.Login(account.Email, account.Password).Wait(); err != nil {
		c.Close()
		return nil, fmt.Errorf("login failed: %w", err)
	}
	return &Client{c: c}, nil
}

// Close closes the IMAP connection.
func (cl *Client) Close() error {
	return cl.c.Logout().Wait()
}

// FetchInbox returns the latest `limit` messages from INBOX (headers only).
func (cl *Client) FetchInbox(limit int) ([]Message, error) {
	if _, err := cl.selectInbox(); err != nil {
		return nil, err
	}

	searchData, err := cl.c.Search(&imap.SearchCriteria{}, nil).Wait()
	if err != nil {
		return nil, fmt.Errorf("searching messages: %w", err)
	}

	ids := searchData.AllSeqNums()
	if len(ids) == 0 {
		return []Message{}, nil
	}

	if limit > 0 && len(ids) > limit {
		ids = ids[len(ids)-limit:]
	}
	reverseSeqNums(ids)

	return cl.fetchMessagesBySeqNums(ids, false)
}

// SearchMessages returns messages in INBOX matching the provided filters.
func (cl *Client) SearchMessages(query SearchQuery) ([]Message, error) {
	if _, err := cl.selectInbox(); err != nil {
		return nil, err
	}

	criteria := &imap.SearchCriteria{}
	if query.From != "" {
		criteria.Header = append(criteria.Header, imap.SearchCriteriaHeaderField{Key: "FROM", Value: query.From})
	}
	if query.Subject != "" {
		criteria.Header = append(criteria.Header, imap.SearchCriteriaHeaderField{Key: "SUBJECT", Value: query.Subject})
	}
	if query.Text != "" {
		criteria.Text = append(criteria.Text, query.Text)
	}
	if !query.Since.IsZero() {
		criteria.Since = query.Since
	}
	if !query.Before.IsZero() {
		criteria.Before = query.Before
	}
	if query.Unseen {
		criteria.NotFlag = append(criteria.NotFlag, imap.FlagSeen)
	}

	searchData, err := cl.c.Search(criteria, nil).Wait()
	if err != nil {
		return nil, fmt.Errorf("searching messages: %w", err)
	}

	ids := searchData.AllSeqNums()
	if len(ids) == 0 {
		return []Message{}, nil
	}

	if query.Limit > 0 && len(ids) > query.Limit {
		ids = ids[len(ids)-query.Limit:]
	}
	reverseSeqNums(ids)

	return cl.fetchMessagesBySeqNums(ids, false)
}

// FetchMessage returns the full message including body.
func (cl *Client) FetchMessage(id uint32) (*Message, error) {
	if _, err := cl.selectInbox(); err != nil {
		return nil, err
	}

	msgs, err := cl.fetchMessagesBySeqNums([]uint32{id}, true)
	if err != nil {
		return nil, err
	}
	if len(msgs) == 0 {
		return nil, fmt.Errorf("message %d not found", id)
	}

	msg := msgs[0]
	return &msg, nil
}

// Watch watches INBOX for new messages, preferring IMAP IDLE and falling back to polling when IDLE isn't supported.
func (cl *Client) Watch(ctx context.Context, fallbackPollInterval time.Duration, onNew func(Message) error) error {
	err := cl.WatchIDLE(ctx, onNew)
	if err == nil || ctx.Err() != nil || !errors.Is(err, ErrIdleUnsupported) {
		return err
	}
	return cl.WatchPoll(ctx, fallbackPollInterval, onNew)
}

// WatchIDLE watches INBOX for new messages using IMAP IDLE.
func (cl *Client) WatchIDLE(ctx context.Context, onNew func(Message) error) error {
	if onNew == nil {
		return fmt.Errorf("watch callback is required")
	}

	selected, err := cl.selectInbox()
	if err != nil {
		return err
	}
	if !cl.supportsIdle() {
		return ErrIdleUnsupported
	}

	lastCount := selected.NumMessages
	checkTicker := time.NewTicker(time.Second)
	defer checkTicker.Stop()

	for {
		if err := ctx.Err(); err != nil {
			return nil
		}

		idleCmd, err := cl.c.Idle()
		if err != nil {
			if isIdleUnsupportedError(err) {
				return fmt.Errorf("%w: %v", ErrIdleUnsupported, err)
			}
			return fmt.Errorf("starting IDLE: %w", err)
		}

		waitCh := make(chan error, 1)
		go func() {
			waitCh <- idleCmd.Wait()
		}()

		shouldRestart := false
		for !shouldRestart {
			select {
			case <-ctx.Done():
				return stopIdle(idleCmd, waitCh)
			case err := <-waitCh:
				if err != nil {
					if isIdleUnsupportedError(err) {
						return fmt.Errorf("%w: %v", ErrIdleUnsupported, err)
					}
					return fmt.Errorf("waiting for IDLE: %w", err)
				}
				shouldRestart = true
			case <-checkTicker.C:
				currentCount := cl.mailboxCount()
				switch {
				case currentCount > lastCount:
					if err := stopIdle(idleCmd, waitCh); err != nil {
						return err
					}
					if err := cl.emitNewMessages(lastCount, currentCount, onNew); err != nil {
						return err
					}
					lastCount = currentCount
					shouldRestart = true
				case currentCount < lastCount:
					lastCount = currentCount
				}
			}
		}
	}
}

// WatchPoll watches INBOX for new messages by polling mailbox UIDNEXT.
func (cl *Client) WatchPoll(ctx context.Context, interval time.Duration, onNew func(Message) error) error {
	if onNew == nil {
		return fmt.Errorf("watch callback is required")
	}
	if interval <= 0 {
		return fmt.Errorf("poll interval must be greater than zero")
	}

	// Get initial UIDNEXT via SELECT (not STATUS — Naver caches STATUS results)
	selected, err := cl.selectInbox()
	if err != nil {
		return err
	}
	lastUIDNext := selected.UIDNext

	ticker := time.NewTicker(interval)
	defer ticker.Stop()

	for {
		select {
		case <-ctx.Done():
			return nil
		case <-ticker.C:
			// Re-SELECT to get fresh UIDNEXT (Naver doesn't update STATUS for selected mailbox)
			reselected, err := cl.c.Select("INBOX", nil).Wait()
			if err != nil {
				return fmt.Errorf("re-selecting INBOX: %w", err)
			}
			currentUIDNext := reselected.UIDNext

			if currentUIDNext > lastUIDNext {
				if err := cl.emitNewMessagesByUID(lastUIDNext, currentUIDNext, onNew); err != nil {
					return err
				}
				lastUIDNext = currentUIDNext
			} else if currentUIDNext < lastUIDNext {
				// UIDVALIDITY changed, reset
				lastUIDNext = currentUIDNext
			}
		}
	}
}

func (cl *Client) selectInbox() (*imap.SelectData, error) {
	selected, err := cl.c.Select("INBOX", nil).Wait()
	if err != nil {
		return nil, fmt.Errorf("selecting INBOX: %w", err)
	}
	return selected, nil
}

func (cl *Client) inboxStatus() (mailboxStatus, error) {
	data, err := cl.c.Status("INBOX", &imap.StatusOptions{UIDNext: true, UIDValidity: true}).Wait()
	if err != nil {
		return mailboxStatus{}, fmt.Errorf("checking INBOX status: %w", err)
	}
	return mailboxStatus{uidNext: data.UIDNext, uidValidity: data.UIDValidity}, nil
}

func (cl *Client) supportsIdle() bool {
	caps := cl.c.Caps()
	return caps.Has(imap.CapIMAP4rev2) || caps.Has(imap.CapIdle)
}

func (cl *Client) fetchMessagesBySeqNums(seqNums []uint32, includeBody bool) ([]Message, error) {
	if len(seqNums) == 0 {
		return []Message{}, nil
	}

	fetchOptions := &imap.FetchOptions{
		Envelope: true,
		Flags:    true,
		UID:      true,
	}
	if includeBody {
		fetchOptions.BodySection = []*imap.FetchItemBodySection{{}}
	}

	msgBuffers, err := cl.c.Fetch(imap.SeqSetNum(seqNums...), fetchOptions).Collect()
	if err != nil {
		if includeBody {
			return nil, fmt.Errorf("fetching message: %w", err)
		}
		return nil, fmt.Errorf("fetching messages: %w", err)
	}
	if len(msgBuffers) == 0 {
		return []Message{}, nil
	}

	bySeqNum := make(map[uint32]Message, len(msgBuffers))
	for _, msg := range msgBuffers {
		parsed := parseMessageBuffer(msg)
		if includeBody {
			for _, section := range msg.BodySection {
				if len(section.Bytes) > 0 {
					parsed.Body = extractPlainText(section.Bytes)
					break
				}
			}
		}
		bySeqNum[msg.SeqNum] = parsed
	}

	result := make([]Message, 0, len(seqNums))
	for _, seqNum := range seqNums {
		if msg, ok := bySeqNum[seqNum]; ok {
			result = append(result, msg)
		}
	}
	return result, nil
}

func (cl *Client) fetchMessagesByUIDSet(uidSet imap.UIDSet, includeBody bool) ([]Message, error) {
	if len(uidSet) == 0 {
		return []Message{}, nil
	}

	fetchOptions := &imap.FetchOptions{
		Envelope: true,
		Flags:    true,
		UID:      true,
	}
	if includeBody {
		fetchOptions.BodySection = []*imap.FetchItemBodySection{{}}
	}

	msgBuffers, err := cl.c.Fetch(uidSet, fetchOptions).Collect()
	if err != nil {
		if includeBody {
			return nil, fmt.Errorf("fetching message by UID: %w", err)
		}
		return nil, fmt.Errorf("fetching messages by UID: %w", err)
	}
	if len(msgBuffers) == 0 {
		return []Message{}, nil
	}

	sort.Slice(msgBuffers, func(i, j int) bool {
		return msgBuffers[i].UID < msgBuffers[j].UID
	})

	result := make([]Message, 0, len(msgBuffers))
	for _, msg := range msgBuffers {
		parsed := parseMessageBuffer(msg)
		if includeBody {
			for _, section := range msg.BodySection {
				if len(section.Bytes) > 0 {
					parsed.Body = extractPlainText(section.Bytes)
					break
				}
			}
		}
		result = append(result, parsed)
	}
	return result, nil
}

func (cl *Client) emitNewMessages(lastCount, currentCount uint32, onNew func(Message) error) error {
	if currentCount <= lastCount {
		return nil
	}

	seqNums := make([]uint32, 0, currentCount-lastCount)
	for seqNum := lastCount + 1; seqNum <= currentCount; seqNum++ {
		seqNums = append(seqNums, seqNum)
	}

	msgs, err := cl.fetchMessagesBySeqNums(seqNums, false)
	if err != nil {
		return err
	}
	for _, msg := range msgs {
		if err := onNew(msg); err != nil {
			return err
		}
	}
	return nil
}

func (cl *Client) emitNewMessagesByUID(lastUIDNext, currentUIDNext imap.UID, onNew func(Message) error) error {
	if currentUIDNext <= lastUIDNext || lastUIDNext == 0 {
		return nil
	}

	var uidSet imap.UIDSet
	uidSet.AddRange(lastUIDNext, currentUIDNext-1)

	msgs, err := cl.fetchMessagesByUIDSet(uidSet, false)
	if err != nil {
		return err
	}
	for _, msg := range msgs {
		if err := onNew(msg); err != nil {
			return err
		}
	}
	return nil
}

func (cl *Client) mailboxCount() uint32 {
	mailbox := cl.c.Mailbox()
	if mailbox == nil {
		return 0
	}
	return mailbox.NumMessages
}

func isIdleUnsupportedError(err error) bool {
	if err == nil {
		return false
	}

	msg := strings.ToLower(err.Error())
	return strings.Contains(msg, "idle") &&
		(strings.Contains(msg, " bad") ||
			strings.Contains(msg, "unsupported") ||
			strings.Contains(msg, "not supported") ||
			strings.Contains(msg, "unknown command") ||
			strings.Contains(msg, "capability"))
}

func stopIdle(idleCmd *imapclient.IdleCommand, waitCh <-chan error) error {
	if err := idleCmd.Close(); err != nil && !strings.Contains(err.Error(), "already closed") {
		return fmt.Errorf("stopping IDLE: %w", err)
	}
	if err := <-waitCh; err != nil {
		return fmt.Errorf("waiting for IDLE: %w", err)
	}
	return nil
}

func parseMessageBuffer(msg *imapclient.FetchMessageBuffer) Message {
	m := Message{
		ID:     msg.SeqNum,
		IsRead: containsFlag(msg.Flags, imap.FlagSeen),
	}
	if msg.Envelope != nil {
		m.Subject = decodeHeader(msg.Envelope.Subject)
		m.Date = msg.Envelope.Date
		if len(msg.Envelope.From) > 0 {
			m.From = formatAddress(msg.Envelope.From[0])
		}
	}
	return m
}

func formatAddress(addr imap.Address) string {
	if addr.Name != "" {
		return fmt.Sprintf("%s <%s@%s>", decodeHeader(addr.Name), addr.Mailbox, addr.Host)
	}
	return fmt.Sprintf("%s@%s", addr.Mailbox, addr.Host)
}

func reverseSeqNums(nums []uint32) {
	for i, j := 0, len(nums)-1; i < j; i, j = i+1, j-1 {
		nums[i], nums[j] = nums[j], nums[i]
	}
}

// decodeHeader decodes RFC 2047 encoded headers (handles EUC-KR).
func decodeHeader(s string) string {
	dec := new(mime.WordDecoder)
	dec.CharsetReader = func(cs string, r io.Reader) (io.Reader, error) {
		cs = strings.ToLower(strings.ReplaceAll(cs, "-", ""))
		switch cs {
		case "euckr", "ksc56011987":
			return transform.NewReader(r, korean.EUCKR.NewDecoder()), nil
		}
		return r, nil
	}
	decoded, err := dec.DecodeHeader(s)
	if err != nil {
		return s
	}
	return decoded
}

// extractPlainText attempts to get plain text from a raw message body.
func extractPlainText(body []byte) string {
	s := string(body)
	// Very basic: strip headers, return body
	if idx := strings.Index(s, "\r\n\r\n"); idx >= 0 {
		return strings.TrimSpace(s[idx+4:])
	}
	if idx := strings.Index(s, "\n\n"); idx >= 0 {
		return strings.TrimSpace(s[idx+2:])
	}
	return strings.TrimSpace(s)
}

func containsFlag(flags []imap.Flag, target imap.Flag) bool {
	for _, flag := range flags {
		if flag == target {
			return true
		}
	}
	return false
}


path: internal/output/output.go
content: package output

import (
	"encoding/json"
	"fmt"
	"os"
)

// JSON prints v as indented JSON to stdout.
func JSON(v any) {
	enc := json.NewEncoder(os.Stdout)
	enc.SetIndent("", "  ")
	if err := enc.Encode(v); err != nil {
		fmt.Fprintf(os.Stderr, "error encoding JSON: %v\n", err)
		os.Exit(1)
	}
}

// Error prints an error as JSON to stdout (agent-friendly).
func Error(msg string) {
	JSON(map[string]string{"error": msg})
}

// Pretty prints a human-readable string.
func Pretty(format string, args ...any) {
	fmt.Printf(format+"\n", args...)
}


path: internal/smtp/smtp.go
content: // Package smtp provides SMTP client functionality for nmail.
package smtp

import (
	"crypto/tls"
	"fmt"
	"mime"
	"net"
	"net/smtp"
	"strconv"
	"strings"
	"time"

	"github.com/harlock/nmail/internal/config"
)

// Send sends an email via SMTP (STARTTLS).
func Send(account config.Account, to, subject, body string) error {
	addr := net.JoinHostPort(account.SMTPHost, strconv.Itoa(account.SMTPPort))

	conn, err := net.Dial("tcp", addr)
	if err != nil {
		return fmt.Errorf("connecting to %s: %w", addr, err)
	}

	c, err := smtp.NewClient(conn, account.SMTPHost)
	if err != nil {
		return fmt.Errorf("creating SMTP client: %w", err)
	}
	defer c.Close()

	// STARTTLS
	if err := c.StartTLS(&tls.Config{ServerName: account.SMTPHost}); err != nil {
		return fmt.Errorf("STARTTLS failed: %w", err)
	}

	// Auth
	auth := smtp.PlainAuth("", account.Email, account.Password, account.SMTPHost)
	if err := c.Auth(auth); err != nil {
		return fmt.Errorf("authentication failed: %w", err)
	}

	// Envelope
	if err := c.Mail(account.Email); err != nil {
		return fmt.Errorf("MAIL FROM: %w", err)
	}

	recipients := strings.Split(to, ",")
	for _, r := range recipients {
		r = strings.TrimSpace(r)
		if err := c.Rcpt(r); err != nil {
			return fmt.Errorf("RCPT TO %s: %w", r, err)
		}
	}

	// Message
	w, err := c.Data()
	if err != nil {
		return fmt.Errorf("DATA: %w", err)
	}

	encodedSubject := mime.QEncoding.Encode("UTF-8", subject)
	msg := fmt.Sprintf(
		"From: %s\r\nTo: %s\r\nSubject: %s\r\nDate: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\n%s",
		account.Email,
		to,
		encodedSubject,
		time.Now().Format("Mon, 02 Jan 2006 15:04:05 -0700"),
		body,
	)

	if _, err := w.Write([]byte(msg)); err != nil {
		return fmt.Errorf("writing message: %w", err)
	}
	if err := w.Close(); err != nil {
		return fmt.Errorf("closing DATA: %w", err)
	}

	return c.Quit()
}


path: main.go
content: package main

import "github.com/harlock/nmail/cmd"

func main() {
	cmd.Execute()
}


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)