feat: guide Gmail OAuth setup

Add an interactive setup wizard for the Gmail API, OAuth consent
screen, audience, test users, client creation, browser authorization,
and git send-email configuration.

Add a doctor command, private atomic credential storage, clearer
command errors, and callback lifecycle tests. Update the README to
match the complete guided flow.

Signed-off-by: Christian Stewart <christian@aperture.us>
This commit is contained in:
Christian Stewart
2026-08-29 15:37:06 -07:00
parent 27b9525aa9
commit d42bff3d32
5 changed files with 711 additions and 186 deletions
+70 -63
View File
@@ -20,90 +20,97 @@ that rewrite by converting plain-text message bodies to quoted-printable MIME.
The transport lines remain within the MIME limit, and the recipient's mail
client reconstructs the original patch lines.
## Setup
## Quick start
### Enable the API
Install the command:
1. Go to the [Google Cloud console](https://console.cloud.google.com/marketplace/product/google/gmail.googleapis.com) and enable the Gmail API.
### Configure the OAuth consent screen
1. In the Google Cloud console, go to [OAuth consent screen](https://console.cloud.google.com/apis/credentials/consent).
2. For User type, select Internal, then click Create.
3. Complete the app registration form, then click Save and Continue.
4. Skip adding scopes and click Save and Continue.
5. Review your app registration summary. To make changes, click Edit. If the app registration looks OK, click Back to Dashboard.
### Authorize credentials for a web application
1. In the Google Cloud console, go to [Credentials](https://console.cloud.google.com/apis/credentials).
2. Click Create Credentials > OAuth client ID.
3. Click Application type > Web application.
4. In the Name field, type a name for the credential like "sendgmailapi".
5. Add http://localhost:8090 as an authorized redirect URI.
6. Click Create. The OAuth client created screen appears, showing your new Client ID and Client secret.
7. Download the JSON file with the credentials.
Note: This application now uses a local server to handle the OAuth2 flow, which is more secure and doesn't rely on external services.
### Set up credentials
1. Create a directory for configuration:
```
mkdir -p ~/.config/sendgmail
chmod 0700 ~/.config/sendgmail
```
2. Move the downloaded JSON file to this directory:
```
mv ~/Downloads/client_secret*.json ~/.config/sendgmail/credentials.json
chmod 0600 ~/.config/sendgmail/credentials.json
```
### Add test user
1. Go back to APIs & Services > OAuth consent screen in the Google Cloud console.
2. Add your Gmail address (e.g., USERNAME@gmail.com) as a test user.
## Usage
Install sendgmailapi:
```
```sh
go install github.com/paralin/sendgmailapi@latest
```
Run the setup to get the token:
Start the interactive setup wizard:
```
$(go env GOPATH)/bin/sendgmailapi -setup
```sh
sendgmailapi setup
```
This will open a browser window for you to authorize the application and generate the token.
The wizard opens each Google Cloud page and pauses while you complete these
steps:
Once set up, you can use SendGmailAPI to send emails. The application reads the email content from standard input.
1. Select or create a Google Cloud project and enable the Gmail API.
2. Configure the OAuth consent screen in Google Auth Platform. Enter the app
name and contact email, choose **Internal** only for the intended Google
Workspace organization, or choose **External**, then finish and save the
initial app configuration.
3. After saving the app, open **Data Access**, click **Add or remove scopes**,
select `https://www.googleapis.com/auth/gmail.send`, click **Update**, and
save the Data Access changes.
4. For an External app in Testing, open **Audience** and add your Gmail address
under **Test users**. Google may expire refresh tokens after seven days
while the app remains in Testing. Move it to Production for lasting
authorization; Google may show an unverified-app warning or require
verification.
5. Create a **Web application** OAuth client. Add
`http://localhost:8090` under **Authorized redirect URIs** exactly as shown.
6. Download the client JSON. The wizard finds it in `~/Downloads`, or asks for
its path.
7. Sign in to Google and approve the `gmail.send` permission.
8. Let the wizard configure `git send-email`.
Add to your .gitconfig at ~/.gitconfig:
Use the same Google Cloud project for the Gmail API, consent screen, audience,
and OAuth client. Enter an app name such as `SendGmailAPI`, and select your
email address for the user-support and developer-contact fields.
```
git config --global sendemail.smtpServer $(go env GOPATH)/bin/sendgmailapi
You can also give the downloaded file directly:
```sh
sendgmailapi setup ~/Downloads/client_secret_....json
```
Or to send a simple email:
If the Google consent screen is in testing mode, add your Gmail account as a
test user before signing in. The wizard stores the OAuth client and token with
private file permissions under `~/.config/sendgmail/`.
```
echo "Subject: Test Email
To: recipient@example.com
Content-Type: text/plain; charset=UTF-8
Check the setup without sending mail:
This is a test email." | sendgmailapi
```sh
sendgmailapi doctor
```
Inspect the Gmail-safe MIME message without sending it:
## Send patches
After setup, use `git send-email` normally:
```sh
git send-email --to recipient@example.com outgoing/*.patch
```
sendgmailapi -encode-only < message.eml
SendGmailAPI runs as the configured sendmail command. It converts plain-text
messages to quoted-printable MIME before calling the Gmail API, so Gmail does
not wrap long patch lines.
## Other commands
Send an RFC 5322 message directly:
```sh
cat message.eml | sendgmailapi
```
Inspect the Gmail-safe MIME without sending it:
```sh
sendgmailapi encode < message.eml
```
Show command help:
```sh
sendgmailapi help
```
The legacy `-setup` and `-encode-only` flags remain available.
## License
MIT
+150 -123
View File
@@ -9,10 +9,10 @@ import (
"flag"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"golang.org/x/oauth2"
@@ -21,56 +21,77 @@ import (
"google.golang.org/api/option"
)
const redirectURI = "http://localhost:8090"
var (
dummyF string
dummyI bool
const (
callbackAddr = "localhost:8090"
redirectURI = "http://" + callbackAddr
)
func getConfig(file string) (*oauth2.Config, error) {
b, err := os.ReadFile(file)
contents, err := os.ReadFile(file)
if err != nil {
return nil, fmt.Errorf("read client secret file: %w", err)
}
config, err := google.ConfigFromJSON(b, gmail.GmailSendScope)
config, err := google.ConfigFromJSON(contents, gmail.GmailSendScope)
if err != nil {
return nil, fmt.Errorf("parse client secret file: %w", err)
}
return config, nil
}
func getClient(config *oauth2.Config, tokenFile string) (*http.Client, error) {
tok, err := tokenFromFile(tokenFile)
if err != nil {
tok, err = getTokenFromWeb(config)
if err != nil {
return nil, err
}
if err := saveToken(tokenFile, tok); err != nil {
return nil, err
}
}
return config.Client(context.Background(), tok), nil
}
func tokenFromFile(file string) (*oauth2.Token, error) {
f, err := os.Open(file)
input, err := os.Open(file)
if err != nil {
return nil, err
}
defer f.Close()
defer input.Close()
tok := &oauth2.Token{}
if err := json.NewDecoder(f).Decode(tok); err != nil {
token := &oauth2.Token{}
if err := json.NewDecoder(input).Decode(token); err != nil {
return nil, err
}
return tok, nil
return token, nil
}
func getTokenFromWeb(config *oauth2.Config) (*oauth2.Token, error) {
func oauthCallbackHandler(state string, codeCh chan<- string, errCh chan<- error) http.Handler {
sendError := func(err error) {
select {
case errCh <- err:
default:
}
}
mux := http.NewServeMux()
mux.HandleFunc("/", func(response http.ResponseWriter, request *http.Request) {
if request.URL.Path != "/" {
http.NotFound(response, request)
return
}
if oauthError := request.FormValue("error"); oauthError != "" {
sendError(fmt.Errorf("Google authorization failed: %s", oauthError))
http.Error(response, "Authorization failed. Return to the terminal.", http.StatusBadRequest)
return
}
if request.FormValue("state") != state {
sendError(fmt.Errorf("invalid OAuth state"))
http.Error(response, "Invalid authorization state.", http.StatusBadRequest)
return
}
code := request.FormValue("code")
if code == "" {
sendError(fmt.Errorf("Google returned no authorization code"))
http.Error(response, "Authorization code is missing.", http.StatusBadRequest)
return
}
select {
case codeCh <- code:
default:
}
_, _ = fmt.Fprintln(response, "Authorization successful. You can close this window.")
})
return mux
}
func getTokenFromWeb(config *oauth2.Config, launchBrowser bool) (*oauth2.Token, error) {
stateBytes := make([]byte, 32)
if _, err := rand.Read(stateBytes); err != nil {
return nil, fmt.Errorf("create OAuth state: %w", err)
@@ -79,128 +100,82 @@ func getTokenFromWeb(config *oauth2.Config) (*oauth2.Token, error) {
codeCh := make(chan string, 1)
errCh := make(chan error, 1)
mux := http.NewServeMux()
server := &http.Server{
Addr: redirectURI[7:],
Handler: mux,
Addr: callbackAddr,
Handler: oauthCallbackHandler(state, codeCh, errCh),
ReadHeaderTimeout: 10 * time.Second,
}
defer server.Shutdown(context.Background())
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
if r.FormValue("state") != state {
errCh <- fmt.Errorf("invalid OAuth state")
http.Error(w, "Invalid state", http.StatusBadRequest)
return
}
codeCh <- r.FormValue("code")
_, _ = fmt.Fprintln(w, "Authorization successful. You can close this window.")
go func() {
if err := server.Shutdown(context.Background()); err != nil {
log.Printf("shut down OAuth server: %v", err)
}
}()
})
listener, err := net.Listen("tcp", server.Addr)
if err != nil {
return nil, fmt.Errorf("listen for OAuth callback on %s: %w", server.Addr, err)
}
go func() {
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
errCh <- fmt.Errorf("serve OAuth callback: %w", err)
if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
select {
case errCh <- fmt.Errorf("serve OAuth callback: %w", err):
default:
}
}
}()
config.RedirectURL = redirectURI
authURL := config.AuthCodeURL(state, oauth2.AccessTypeOffline)
fmt.Printf("Listening on %s\n", redirectURI)
fmt.Printf("Visit this URL to authorize the application:\n%s\n", authURL)
authURL := config.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("prompt", "consent"))
if launchBrowser {
if err := openBrowser(authURL); err != nil {
fmt.Printf("Could not open a browser: %v\n", err)
} else {
fmt.Println("Opened Google sign-in in your browser.")
}
}
fmt.Printf("If needed, open this URL:\n%s\n", authURL)
var code string
select {
case code = <-codeCh:
case err := <-errCh:
return nil, err
case <-time.After(2 * time.Minute):
return nil, fmt.Errorf("authorization timed out")
case <-time.After(5 * time.Minute):
return nil, fmt.Errorf("authorization timed out; run 'sendgmailapi setup' to try again")
}
tok, err := config.Exchange(context.Background(), code)
token, err := config.Exchange(context.Background(), code)
if err != nil {
return nil, fmt.Errorf("exchange OAuth code: %w", err)
}
return tok, nil
return token, nil
}
func saveToken(path string, token *oauth2.Token) error {
fmt.Printf("Saving credential file to: %s\n", path)
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
func runEncode() error {
message, err := io.ReadAll(os.Stdin)
if err != nil {
return fmt.Errorf("open OAuth token file: %w", err)
return fmt.Errorf("read message: %w", err)
}
defer f.Close()
if err := json.NewEncoder(f).Encode(token); err != nil {
return fmt.Errorf("write OAuth token file: %w", err)
message, err = encodeQuotedPrintable(message)
if err != nil {
return fmt.Errorf("encode message: %w", err)
}
if _, err := os.Stdout.Write(message); err != nil {
return fmt.Errorf("write encoded message: %w", err)
}
return nil
}
func setupMode(config *oauth2.Config, tokenFile string) error {
tok, err := getTokenFromWeb(config)
func runSend() error {
files, err := userConfigFiles()
if err != nil {
return err
}
if err := saveToken(tokenFile, tok); err != nil {
return err
}
fmt.Println("Setup completed successfully!")
return nil
}
func run() error {
setupFlag := flag.Bool("setup", false, "Run in setup mode")
encodeOnlyFlag := flag.Bool("encode-only", false, "Write the Gmail-safe MIME message to standard output")
flag.StringVar(&dummyF, "f", "", "Dummy flag for sendmail compatibility")
flag.BoolVar(&dummyI, "i", true, "Dummy flag for sendmail compatibility")
flag.Parse()
if *encodeOnlyFlag {
message, err := io.ReadAll(os.Stdin)
if err != nil {
return fmt.Errorf("read message: %w", err)
}
message, err = encodeQuotedPrintable(message)
if err != nil {
return fmt.Errorf("encode message: %w", err)
}
if _, err := os.Stdout.Write(message); err != nil {
return fmt.Errorf("write encoded message: %w", err)
}
return nil
}
homeDir, err := os.UserHomeDir()
config, err := getConfig(files.credentials)
if err != nil {
return fmt.Errorf("find user home directory: %w", err)
return fmt.Errorf("Gmail is not configured; run 'sendgmailapi setup': %w", err)
}
credentialsFile := filepath.Join(homeDir, ".config", "sendgmail", "credentials.json")
tokenFile := filepath.Join(homeDir, ".config", "sendgmail", "token.json")
config, err := getConfig(credentialsFile)
token, err := tokenFromFile(files.token)
if err != nil {
return fmt.Errorf("load OAuth config: %w", err)
}
if *setupFlag {
return setupMode(config, tokenFile)
}
client, err := getClient(config, tokenFile)
if err != nil {
return fmt.Errorf("create OAuth client: %w", err)
return fmt.Errorf("Gmail is not authorized; run 'sendgmailapi setup': %w", err)
}
client := config.Client(context.Background(), token)
gmailService, err := gmail.NewService(context.Background(), option.WithHTTPClient(client))
if err != nil {
return fmt.Errorf("create Gmail service: %w", err)
@@ -215,17 +190,69 @@ func run() error {
return fmt.Errorf("encode message: %w", err)
}
gmsg := &gmail.Message{Raw: base64.RawURLEncoding.EncodeToString(message)}
if _, err := gmailService.Users.Messages.Send("me", gmsg).Do(); err != nil {
gmailMessage := &gmail.Message{Raw: base64.RawURLEncoding.EncodeToString(message)}
if _, err := gmailService.Users.Messages.Send("me", gmailMessage).Do(); err != nil {
return fmt.Errorf("send email: %w", err)
}
fmt.Println("Message sent successfully!")
fmt.Fprintln(os.Stderr, "Message sent successfully.")
return nil
}
func printUsage() {
fmt.Println(`Send email through the Gmail API without breaking patch lines.
Usage:
sendgmailapi setup [credentials.json] Guided Gmail sign-in and git setup
sendgmailapi doctor Check configuration and authorization
sendgmailapi encode Write Gmail-safe MIME without sending
sendgmailapi help Show this help
With no command, sendgmailapi reads an RFC 5322 message from standard input.
This is the sendmail-compatible mode used by git send-email.`)
}
func runArgs(args []string) error {
if len(args) > 0 {
switch args[0] {
case "setup":
return runSetup(args[1:])
case "doctor":
return runDoctor()
case "encode":
return runEncode()
case "help", "-h", "--help":
printUsage()
return nil
}
}
legacy := flag.NewFlagSet("sendgmailapi", flag.ContinueOnError)
setup := legacy.Bool("setup", false, "Run the setup wizard")
encodeOnly := legacy.Bool("encode-only", false, "Write Gmail-safe MIME to standard output")
legacy.String("f", "", "Sendmail compatibility")
legacy.Bool("i", true, "Sendmail compatibility")
if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
return fmt.Errorf("unknown command %q; run 'sendgmailapi help'", args[0])
}
if err := legacy.Parse(args); err != nil {
return err
}
if *setup {
return runSetup(nil)
}
if *encodeOnly {
return runEncode()
}
return runSend()
}
func run() error {
return runArgs(os.Args[1:])
}
func main() {
if err := run(); err != nil {
log.Fatal(err)
fmt.Fprintf(os.Stderr, "sendgmailapi: %v\n", err)
os.Exit(1)
}
}
+64
View File
@@ -0,0 +1,64 @@
package main
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestOAuthCallbackHandler(t *testing.T) {
tests := []struct {
name string
target string
wantStatus int
wantCode string
wantError string
}{
{name: "success", target: "/?state=expected&code=code", wantStatus: http.StatusOK, wantCode: "code"},
{name: "wrong state", target: "/?state=wrong&code=code", wantStatus: http.StatusBadRequest, wantError: "invalid OAuth state"},
{name: "Google error", target: "/?error=access_denied", wantStatus: http.StatusBadRequest, wantError: "access_denied"},
{name: "missing code", target: "/?state=expected", wantStatus: http.StatusBadRequest, wantError: "no authorization code"},
{name: "not found", target: "/other", wantStatus: http.StatusNotFound},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
codeCh := make(chan string, 1)
errCh := make(chan error, 1)
request := httptest.NewRequest(http.MethodGet, test.target, nil)
response := httptest.NewRecorder()
oauthCallbackHandler("expected", codeCh, errCh).ServeHTTP(response, request)
if response.Code != test.wantStatus {
t.Fatalf("status = %d, want %d", response.Code, test.wantStatus)
}
if test.wantCode != "" {
select {
case code := <-codeCh:
if code != test.wantCode {
t.Fatalf("code = %q, want %q", code, test.wantCode)
}
default:
t.Fatal("callback produced no code")
}
}
if test.wantError != "" {
select {
case err := <-errCh:
if !strings.Contains(err.Error(), test.wantError) {
t.Fatalf("error = %q, want text %q", err, test.wantError)
}
default:
t.Fatal("callback produced no error")
}
}
})
}
}
func TestRunArgsRejectsUnknownCommand(t *testing.T) {
err := runArgs([]string{"staus"})
if err == nil || !strings.Contains(err.Error(), "unknown command") {
t.Fatalf("runArgs() error = %v, want unknown command", err)
}
}
+372
View File
@@ -0,0 +1,372 @@
package main
import (
"bufio"
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"io/fs"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strings"
"time"
"golang.org/x/oauth2"
)
type configFiles struct {
directory string
credentials string
token string
}
func userConfigFiles() (configFiles, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return configFiles{}, fmt.Errorf("find user home directory: %w", err)
}
directory := filepath.Join(homeDir, ".config", "sendgmail")
return configFiles{
directory: directory,
credentials: filepath.Join(directory, "credentials.json"),
token: filepath.Join(directory, "token.json"),
}, nil
}
func runSetup(args []string) error {
flags := flag.NewFlagSet("setup", flag.ContinueOnError)
noBrowser := flags.Bool("no-browser", false, "Print URLs instead of opening a browser")
if err := flags.Parse(args); err != nil {
return err
}
if flags.NArg() > 1 {
return fmt.Errorf("usage: sendgmailapi setup [--no-browser] [credentials.json]")
}
fmt.Println("SendGmailAPI setup")
fmt.Println("==================")
fmt.Println("This wizard connects your Gmail account and configures git send-email.")
reader := bufio.NewReader(os.Stdin)
files, err := userConfigFiles()
if err != nil {
return err
}
if err := os.MkdirAll(files.directory, 0700); err != nil {
return fmt.Errorf("create configuration directory: %w", err)
}
if err := os.Chmod(files.directory, 0700); err != nil {
return fmt.Errorf("secure configuration directory: %w", err)
}
credentialsSource := ""
if flags.NArg() == 1 {
credentialsSource = flags.Arg(0)
} else if _, err := os.Stat(files.credentials); errors.Is(err, fs.ErrNotExist) {
credentialsSource, err = guideCredentials(reader, !*noBrowser)
if err != nil {
return err
}
}
if credentialsSource != "" {
if err := installCredentials(credentialsSource, files.credentials); err != nil {
return err
}
fmt.Printf("OAuth client: imported %s\n", credentialsSource)
} else {
fmt.Printf("OAuth client: using %s\n", files.credentials)
}
config, err := getConfig(files.credentials)
if err != nil {
return fmt.Errorf("load OAuth client: %w", err)
}
fmt.Println("\nStep 5 of 6: Sign in to Gmail")
token, err := getTokenFromWeb(config, !*noBrowser)
if err != nil {
return err
}
if err := saveToken(files.token, token); err != nil {
return err
}
fmt.Println("Gmail authorization: saved")
executable, err := os.Executable()
if err != nil {
executable = "sendgmailapi"
}
fmt.Println("\nStep 6 of 6: Configure git send-email")
if askYesNo(reader, "Configure git send-email now?", true) {
command := exec.Command("git", "config", "--global", "sendemail.smtpServer", executable)
if output, err := command.CombinedOutput(); err != nil {
return fmt.Errorf("configure git send-email: %w: %s", err, strings.TrimSpace(string(output)))
}
fmt.Printf("git send-email: configured to use %s\n", executable)
} else {
fmt.Println("Run this later:")
fmt.Printf(" git config --global sendemail.smtpServer %q\n", executable)
}
fmt.Println("\nSetup complete. Verify it with:")
fmt.Println(" sendgmailapi doctor")
return nil
}
func guideCredentials(reader *bufio.Reader, launchBrowser bool) (string, error) {
fmt.Println("\nGoogle Cloud configuration")
fmt.Println("The wizard can reuse a client JSON that you already downloaded.")
if downloaded, err := findDownloadedCredentials(); err == nil {
fmt.Printf("Found: %s\n", downloaded)
if askYesNo(reader, "Use this OAuth client and skip Google Cloud setup?", true) {
return downloaded, nil
}
}
fmt.Println("\nStep 1 of 6: Select a project and enable the Gmail API")
fmt.Println("In Google Cloud:")
fmt.Println(" 1. Select an existing project or create a new project.")
fmt.Println(" 2. Open the Gmail API page.")
fmt.Println(" 3. Click Enable if the API is not already enabled.")
visitPage(reader, launchBrowser, "Open the Gmail API page?", "https://console.cloud.google.com/apis/library/gmail.googleapis.com")
if _, err := readLine(reader, "Press Enter after the Gmail API is enabled..."); err != nil {
return "", err
}
fmt.Println("\nStep 2 of 6: Configure the OAuth consent screen")
fmt.Println("Open Google Auth Platform for the same project, then:")
fmt.Println(" 1. Enter an app name, such as SendGmailAPI.")
fmt.Println(" 2. Select your email for user support and developer contact.")
fmt.Println(" 3. Choose Internal only for the intended Google Workspace organization.")
fmt.Println(" Otherwise choose External.")
fmt.Println(" 4. Finish and save the initial app configuration.")
fmt.Println(" 5. For an External app in Testing, open Audience and add your Gmail")
fmt.Println(" address under Test users.")
fmt.Println("Google may expire refresh tokens after 7 days while an External app is in")
fmt.Println("Testing. Move the app to Production for lasting authorization; Google may")
fmt.Println("show an unverified-app warning or require verification.")
visitPage(reader, launchBrowser, "Open Google Auth Platform?", "https://console.cloud.google.com/auth/overview")
if _, err := readLine(reader, "Press Enter after the initial app configuration is saved..."); err != nil {
return "", err
}
fmt.Println("\nStep 3 of 6: Add Gmail permission under Data Access")
fmt.Println("After saving the initial app configuration:")
fmt.Println(" 1. Open Data Access in Google Auth Platform.")
fmt.Println(" 2. Click Add or remove scopes.")
fmt.Println(" 3. Find and select this Gmail API scope:")
fmt.Println(" https://www.googleapis.com/auth/gmail.send")
fmt.Println(" 4. Click Update, then save the Data Access changes.")
visitPage(reader, launchBrowser, "Open Data Access?", "https://console.cloud.google.com/auth/scopes")
if _, err := readLine(reader, "Press Enter after the gmail.send scope is saved..."); err != nil {
return "", err
}
fmt.Println("\nStep 4 of 6: Create and download an OAuth client")
fmt.Println("In Google Auth Platform > Clients:")
fmt.Println(" 1. Click Create client.")
fmt.Println(" 2. Select Web application.")
fmt.Println(" 3. Add this Authorized redirect URI exactly:")
fmt.Printf(" %s\n", redirectURI)
fmt.Println(" 4. Create the client and download its JSON file.")
visitPage(reader, launchBrowser, "Open the OAuth clients page?", "https://console.cloud.google.com/auth/clients")
if _, err := readLine(reader, "Press Enter after the JSON file finishes downloading..."); err != nil {
return "", err
}
if downloaded, err := findDownloadedCredentials(); err == nil {
fmt.Printf("Found: %s\n", downloaded)
return downloaded, nil
}
path, err := readLine(reader, "The JSON was not found automatically. Enter its path: ")
if err != nil {
return "", err
}
if path == "" {
return "", fmt.Errorf("no OAuth client JSON selected")
}
if strings.HasPrefix(path, "~/") {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", err
}
path = filepath.Join(homeDir, path[2:])
}
return path, nil
}
func visitPage(reader *bufio.Reader, launchBrowser bool, prompt, url string) {
if launchBrowser && askYesNo(reader, prompt, true) {
if err := openBrowser(url); err != nil {
fmt.Printf("Could not open the browser: %v\n", err)
fmt.Printf("Open this URL manually: %s\n", url)
}
return
}
fmt.Printf("Open: %s\n", url)
}
func askYesNo(reader *bufio.Reader, prompt string, defaultYes bool) bool {
suffix := " [Y/n] "
if !defaultYes {
suffix = " [y/N] "
}
for {
answer, err := readLine(reader, prompt+suffix)
if err != nil {
return false
}
switch strings.ToLower(strings.TrimSpace(answer)) {
case "":
return defaultYes
case "y", "yes":
return true
case "n", "no":
return false
default:
fmt.Println("Please answer yes or no.")
}
}
}
func readLine(reader *bufio.Reader, prompt string) (string, error) {
fmt.Print(prompt)
line, err := reader.ReadString('\n')
if err != nil {
if errors.Is(err, io.EOF) && line != "" {
return strings.TrimSpace(line), nil
}
return "", err
}
return strings.TrimSpace(line), nil
}
func runDoctor() error {
files, err := userConfigFiles()
if err != nil {
return err
}
config, err := getConfig(files.credentials)
if err != nil {
return fmt.Errorf("OAuth client: not ready (%w)\nRun: sendgmailapi setup credentials.json", err)
}
fmt.Printf("OAuth client: %s\n", files.credentials)
token, err := tokenFromFile(files.token)
if err != nil {
return fmt.Errorf("Gmail authorization: not ready (%w)\nRun: sendgmailapi setup", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err := config.TokenSource(ctx, token).Token(); err != nil {
return fmt.Errorf("Gmail authorization: invalid (%w)\nRun: sendgmailapi setup", err)
}
fmt.Printf("Gmail authorization: %s\n", files.token)
fmt.Println("Status: ready to send")
return nil
}
func findDownloadedCredentials() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("find user home directory: %w", err)
}
patterns := []string{
filepath.Join(homeDir, "Downloads", "client_secret*.json"),
filepath.Join(homeDir, "Downloads", "credentials*.json"),
}
type candidate struct {
path string
modTime time.Time
}
var candidates []candidate
for _, pattern := range patterns {
matches, err := filepath.Glob(pattern)
if err != nil {
return "", fmt.Errorf("search downloaded OAuth clients: %w", err)
}
for _, match := range matches {
info, err := os.Stat(match)
if err == nil && !info.IsDir() {
candidates = append(candidates, candidate{path: match, modTime: info.ModTime()})
}
}
}
if len(candidates) == 0 {
return "", fmt.Errorf("no OAuth client JSON found in Downloads\nDownload it from Google Cloud, then run: sendgmailapi setup credentials.json")
}
sort.Slice(candidates, func(i, j int) bool {
return candidates[i].modTime.After(candidates[j].modTime)
})
return candidates[0].path, nil
}
func installCredentials(source, destination string) error {
if _, err := getConfig(source); err != nil {
return fmt.Errorf("validate OAuth client %s: %w", source, err)
}
contents, err := os.ReadFile(source)
if err != nil {
return fmt.Errorf("read OAuth client %s: %w", source, err)
}
if err := writePrivateFile(destination, contents); err != nil {
return fmt.Errorf("install OAuth client: %w", err)
}
return nil
}
func writePrivateFile(path string, contents []byte) error {
temporary, err := os.CreateTemp(filepath.Dir(path), ".sendgmailapi-*")
if err != nil {
return err
}
temporaryPath := temporary.Name()
defer os.Remove(temporaryPath)
if err := temporary.Chmod(0600); err != nil {
temporary.Close()
return err
}
if _, err := temporary.Write(contents); err != nil {
temporary.Close()
return err
}
if err := temporary.Close(); err != nil {
return err
}
return os.Rename(temporaryPath, path)
}
func openBrowser(url string) error {
var command *exec.Cmd
switch runtime.GOOS {
case "darwin":
command = exec.Command("open", url)
case "linux":
command = exec.Command("xdg-open", url)
case "windows":
command = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
default:
return fmt.Errorf("automatic browser opening is unsupported on %s", runtime.GOOS)
}
if err := command.Run(); err != nil {
return fmt.Errorf("open browser: %w", err)
}
return nil
}
func saveToken(path string, token *oauth2.Token) error {
contents, err := json.Marshal(token)
if err != nil {
return fmt.Errorf("encode OAuth token: %w", err)
}
if err := writePrivateFile(path, contents); err != nil {
return fmt.Errorf("save OAuth token: %w", err)
}
return nil
}
+55
View File
@@ -0,0 +1,55 @@
package main
import (
"bufio"
"os"
"path/filepath"
"strings"
"testing"
)
func TestAskYesNo(t *testing.T) {
tests := []struct {
name string
input string
defaultYes bool
want bool
}{
{name: "default yes", input: "\n", defaultYes: true, want: true},
{name: "default no", input: "\n", defaultYes: false, want: false},
{name: "yes", input: "yes\n", want: true},
{name: "no", input: "no\n", defaultYes: true, want: false},
{name: "retry invalid", input: "maybe\nyes\n", want: true},
{name: "EOF is not consent", input: "", defaultYes: true, want: false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
reader := bufio.NewReader(strings.NewReader(test.input))
if got := askYesNo(reader, "Continue?", test.defaultYes); got != test.want {
t.Fatalf("askYesNo() = %v, want %v", got, test.want)
}
})
}
}
func TestWritePrivateFile(t *testing.T) {
directory := t.TempDir()
path := filepath.Join(directory, "token.json")
if err := writePrivateFile(path, []byte("token")); err != nil {
t.Fatal(err)
}
contents, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(contents) != "token" {
t.Fatalf("unexpected contents: %q", contents)
}
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if permissions := info.Mode().Perm(); permissions != 0600 {
t.Fatalf("permissions = %o, want 600", permissions)
}
}