mirror of
https://github.com/paralin/sendgmailapi.git
synced 2026-08-31 07:37:57 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user