blob: 72c8dd74287a8a7c869e01b9a6a58e4e74780b90 [file] [log] [blame]
package emailservice
import (
"context"
"encoding/json"
"flag"
"fmt"
"io"
"io/ioutil"
"net/http"
"time"
"github.com/gorilla/mux"
"github.com/sendgrid/sendgrid-go"
"github.com/sendgrid/sendgrid-go/helpers/mail"
"go.skia.org/infra/go/common"
"go.skia.org/infra/go/email"
"go.skia.org/infra/go/httputils"
"go.skia.org/infra/go/metrics2"
"go.skia.org/infra/go/secret"
"go.skia.org/infra/go/skerr"
"go.skia.org/infra/go/sklog"
)
const (
serverReadTimeout = 5 * time.Minute
serverWriteTimeout = 5 * time.Minute
)
type getClientFromKeyType func(ctx context.Context, email, key string) (*email.GMail, error)
// App is the main email service application.
type App struct {
// flags
local bool
port string
project string
promPort string
secretName string
sendgridClient *sendgrid.Client
sendSuccess metrics2.Counter
sendFailure metrics2.Counter
}
// Flagset constructs a flag.FlagSet for the App.
func (a *App) Flagset() *flag.FlagSet {
fs := flag.NewFlagSet("emailservice", flag.ExitOnError)
fs.BoolVar(&a.local, "local", false, "Running locally if true. As opposed to in production.")
fs.StringVar(&a.port, "port", ":8000", "HTTP service address (e.g., ':8000')")
fs.StringVar(&a.project, "project", "skia-public", "The GCP Project that holds the secret.")
fs.StringVar(&a.secretName, "secret-name", "sendgrid-api-key", "The name of the GCP secret that contains the SendGrid API key..")
fs.StringVar(&a.promPort, "prom-port", ":20000", "Metrics service address (e.g., ':10110')")
return fs
}
// New returns a new instance of App.
func New(ctx context.Context) (*App, error) {
var ret App
err := common.InitWith(
"email-service",
common.CloudLogging(&ret.local, "skia-public"),
common.MetricsLoggingOpt(),
common.PrometheusOpt(&ret.promPort),
common.FlagSetOpt(ret.Flagset()),
)
if err != nil {
return nil, skerr.Wrapf(err, "Failed common.InitWith().")
}
secretClient, err := secret.NewClient(context.Background())
if err != nil {
return nil, skerr.Wrapf(err, "Failed creating secret client")
}
sendGridAPIKey, err := secretClient.Get(ctx, ret.project, ret.secretName, secret.VersionLatest)
if err != nil {
return nil, skerr.Wrapf(err, "Failed retrieving secret: %q from project: %q", ret.secretName, ret.project)
}
sklog.Infof("API Key retrieved.")
ret.sendSuccess = metrics2.GetCounter("emailservice_send_success")
ret.sendFailure = metrics2.GetCounter("emailservice_send_failure")
ret.sendgridClient = sendgrid.NewSendClient(sendGridAPIKey)
return &ret, nil
}
func (a *App) reportSendError(w http.ResponseWriter, err error, msg string) {
httputils.ReportError(w, err, msg, http.StatusBadRequest)
a.sendFailure.Inc(1)
}
func convertRFC2822ToSendGrid(r io.Reader) (*mail.SGMailV3, error) {
// Parse the entire incoming RFC2822 body.
body, err := ioutil.ReadAll(r)
if err != nil {
return nil, skerr.Wrapf(err, "Failed to read body.")
}
bodyAsString := string(body)
sklog.Infof("Received: %q", bodyAsString)
from, to, subject, htmlBody, err := email.ParseRFC2822Message(body)
if err != nil {
return nil, skerr.Wrapf(err, "Failed to parse RFC 2822 body.")
}
// Parse the From: line.
parsedFrom, err := mail.ParseEmail(from)
if err != nil {
return nil, skerr.Wrapf(err, "Failed to parse From: address.")
}
m := mail.NewV3Mail()
m.SetFrom(parsedFrom)
m.Subject = subject
// Parse the To: line.
p := mail.NewPersonalization()
tos := []*mail.Email{}
for _, addr := range to {
parsedTo, err := mail.ParseEmail(addr)
if err != nil {
return nil, skerr.Wrapf(err, "Failed to parse To: address.")
}
tos = append(tos, parsedTo)
}
p.AddTos(tos...)
m.AddPersonalizations(p)
c := mail.NewContent("text/html", htmlBody)
m.AddContent(c)
return m, nil
}
// Error is a single error returned in a Response.
type Error struct {
Message string `json:"message"`
Field string `json:"field"`
Help string `json:"help"`
}
// Response is the JSON format of the body the SendGrid API returns.
type Response struct {
Errors []Error `json:"errors,omitempty"`
}
// Handle incoming POST's of RFC2822 formatted emails, which are then parsed and
// sent.
func (a *App) incomingEmaiHandler(w http.ResponseWriter, r *http.Request) {
m, err := convertRFC2822ToSendGrid(r.Body)
if err != nil {
a.reportSendError(w, err, "Failed to convert RFC2822 body to SendGrid API format")
return
}
resp, err := a.sendgridClient.Send(m)
if err != nil {
a.reportSendError(w, err, "Failed to send via API")
return
}
sklog.Infof("Response Body: %q", resp.Body)
sklog.Infof("Response Headers: %s", resp.Headers)
if h, ok := resp.Headers["X-Message-Id"]; ok && len(h) > 0 {
w.Header().Set("X-Message-Id", h[0])
}
var decodedResponse Response
if err := json.Unmarshal([]byte(resp.Body), &decodedResponse); err != nil {
sklog.Warningf("Failed to decode JSON: %s", err)
}
if len(decodedResponse.Errors) > 0 {
a.reportSendError(w, err, fmt.Sprintf("Failed to send via API: %q", resp.Body))
return
}
sklog.Infof("Successfully sent from: %q", m.From.Address)
a.sendSuccess.Inc(1)
}
// Run the email service. This function will only return on failure.
func (a *App) Run() error {
// Add all routing.
r := mux.NewRouter()
r.HandleFunc("/send", a.incomingEmaiHandler).Methods("POST")
// We must specify that we handle /healthz or it will never flow through to
// our middleware. Even though this handler is never actually called (due to
// the early termination in httputils.HealthzAndHTTPS), we need to have it
// added to the routes we handle.
r.HandleFunc("/healthz", httputils.ReadyHandleFunc)
sklog.Infof("Ready to serve on port %s", a.port)
server := &http.Server{
Addr: a.port,
Handler: r,
ReadTimeout: serverReadTimeout,
WriteTimeout: serverWriteTimeout,
MaxHeaderBytes: 1 << 20,
}
return server.ListenAndServe()
}
// Main runs the application. This function will only return on failure.
func Main() error {
app, err := New(context.Background())
if err != nil {
return skerr.Wrap(err)
}
return app.Run()
}