go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/server/mailer/module.go (about)

     1  // Copyright 2021 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package mailer
    16  
    17  import (
    18  	"context"
    19  	"flag"
    20  	"fmt"
    21  	"net/http"
    22  	"strings"
    23  	"time"
    24  
    25  	"github.com/google/uuid"
    26  	"google.golang.org/protobuf/proto"
    27  
    28  	"go.chromium.org/luci/common/errors"
    29  	"go.chromium.org/luci/common/logging"
    30  	"go.chromium.org/luci/common/retry"
    31  	"go.chromium.org/luci/common/retry/transient"
    32  	"go.chromium.org/luci/grpc/grpcutil"
    33  	"go.chromium.org/luci/grpc/prpc"
    34  	"go.chromium.org/luci/mailer/api/mailer"
    35  
    36  	"go.chromium.org/luci/server/auth"
    37  	"go.chromium.org/luci/server/internal/gae"
    38  	gaebasepb "go.chromium.org/luci/server/internal/gae/base"
    39  	gaemailpb "go.chromium.org/luci/server/internal/gae/mail"
    40  	"go.chromium.org/luci/server/module"
    41  )
    42  
    43  // ModuleName can be used to refer to this module when declaring dependencies.
    44  var ModuleName = module.RegisterName("go.chromium.org/luci/server/mailer")
    45  
    46  // ModuleOptions contain configuration of the mailer server module.
    47  //
    48  // It will be used to initialize the mailer in the context.
    49  type ModuleOptions struct {
    50  	// MailerService defines what mailing backend to use.
    51  	//
    52  	// Supported values are:
    53  	//   * "https://<host>"" to use a luci.mailer.v1.Mailer pRPC service.
    54  	//   * "gae" to use GAE bundled Mail service (works only on GAE, see below).
    55  	//
    56  	// Also "http://<host>" can be used locally to connect to a local pRPC mailer
    57  	// service without TLS. This is useful for local integration tests.
    58  	//
    59  	// Using "gae" backend requires running on GAE and having
    60  	// "app_engine_apis: true" in the module YAML.
    61  	// See https://cloud.google.com/appengine/docs/standard/go/services/access.
    62  	//
    63  	// On GAE defaults to "gae", elsewhere defaults to no backend at all which
    64  	// results in emails being logged in local logs only and not actually sent
    65  	// anywhere.
    66  	MailerService string
    67  
    68  	// DefaultSender is a value to use in "From" email header field by default.
    69  	//
    70  	// Used only if `Sender` field of Mail struct is not populated.
    71  	//
    72  	// On GAE defaults to "<appid> noreply@<appid>.appspotmail.com".
    73  	//
    74  	// When using a pRPC backend, defaults to an empty string, which indicates
    75  	// that the pRPC backend should make the decision itself.
    76  	DefaultSender string
    77  }
    78  
    79  // Register registers the command line flags.
    80  func (o *ModuleOptions) Register(f *flag.FlagSet) {
    81  	f.StringVar(&o.MailerService, "mailer-service", o.MailerService, `What mailing backend to use.`)
    82  	f.StringVar(&o.DefaultSender, "mailer-default-sender", o.DefaultSender, `A value to use in "From" email header field by default.`)
    83  }
    84  
    85  // NewModule returns a server module that initializes the mailer in the context.
    86  func NewModule(opts *ModuleOptions) module.Module {
    87  	if opts == nil {
    88  		opts = &ModuleOptions{}
    89  	}
    90  	return &mailerModule{opts: opts}
    91  }
    92  
    93  // NewModuleFromFlags is a variant of NewModule that initializes options through
    94  // command line flags.
    95  //
    96  // Calling this function registers flags in flag.CommandLine. They are usually
    97  // parsed in server.Main(...).
    98  func NewModuleFromFlags() module.Module {
    99  	opts := &ModuleOptions{}
   100  	opts.Register(flag.CommandLine)
   101  	return NewModule(opts)
   102  }
   103  
   104  // mailerModule implements module.Module.
   105  type mailerModule struct {
   106  	opts *ModuleOptions
   107  }
   108  
   109  // Name is part of module.Module interface.
   110  func (*mailerModule) Name() module.Name {
   111  	return ModuleName
   112  }
   113  
   114  // Dependencies is part of module.Module interface.
   115  func (*mailerModule) Dependencies() []module.Dependency {
   116  	return nil
   117  }
   118  
   119  // Initialize is part of module.Module interface.
   120  func (m *mailerModule) Initialize(ctx context.Context, host module.Host, opts module.HostOptions) (context.Context, error) {
   121  	service := m.opts.MailerService
   122  
   123  	if service == "" {
   124  		if opts.Serverless == module.GAE {
   125  			service = "gae"
   126  		} else {
   127  			logging.Warningf(ctx, "Mailer service is not configured, emails will be dropped")
   128  			return Use(ctx, func(ctx context.Context, msg *Mail) error {
   129  				logging.Errorf(ctx, "No mailer configured: dropping message to %q with subject %q", msg.To, msg.Subject)
   130  				return nil
   131  			}), nil
   132  		}
   133  	}
   134  
   135  	var mailer Mailer
   136  	var err error
   137  
   138  	switch {
   139  	case strings.HasPrefix(service, "https://"):
   140  		mailer, err = m.initRPCMailer(ctx, strings.TrimPrefix(service, "https://"), false)
   141  	case strings.HasPrefix(service, "http://"):
   142  		mailer, err = m.initRPCMailer(ctx, strings.TrimPrefix(service, "http://"), true)
   143  	case service == "gae":
   144  		if opts.Serverless != module.GAE {
   145  			return nil, errors.Reason(`"-mailer-service gae" can only be used on GAE`).Err()
   146  		}
   147  		if m.opts.DefaultSender == "" {
   148  			m.opts.DefaultSender = fmt.Sprintf("%s <noreply@%s.appspotmail.com>", opts.CloudProject, opts.CloudProject)
   149  		}
   150  		mailer, err = m.initGAEMailer(ctx)
   151  	default:
   152  		return nil, errors.Reason("unrecognized -mailer-service %q", service).Err()
   153  	}
   154  
   155  	if err != nil {
   156  		return nil, err
   157  	}
   158  	return Use(ctx, mailer), nil
   159  }
   160  
   161  func (m *mailerModule) sender(msg *Mail) string {
   162  	if msg.Sender != "" {
   163  		return msg.Sender
   164  	}
   165  	return m.opts.DefaultSender
   166  }
   167  
   168  func (m *mailerModule) initRPCMailer(ctx context.Context, host string, insecure bool) (Mailer, error) {
   169  	tr, err := auth.GetRPCTransport(ctx, auth.AsSelf, auth.WithIDToken())
   170  	if err != nil {
   171  		return nil, errors.Annotate(err, "failed to get a RPC transport").Err()
   172  	}
   173  
   174  	mailerClient := mailer.NewMailerClient(&prpc.Client{
   175  		C:    &http.Client{Transport: tr},
   176  		Host: host,
   177  		Options: &prpc.Options{
   178  			Insecure:      insecure,
   179  			PerRPCTimeout: 10 * time.Second,
   180  			Retry: func() retry.Iterator {
   181  				return &retry.ExponentialBackoff{
   182  					Limited: retry.Limited{
   183  						Delay:    50 * time.Millisecond,
   184  						Retries:  -1,
   185  						MaxTotal: 20 * time.Second,
   186  					},
   187  				}
   188  			},
   189  		},
   190  	})
   191  
   192  	return func(ctx context.Context, msg *Mail) error {
   193  		requestID, err := uuid.NewRandom()
   194  		if err != nil {
   195  			return errors.Annotate(err, "failed to generate request ID").Tag(transient.Tag).Err()
   196  		}
   197  		resp, err := mailerClient.SendMail(ctx, &mailer.SendMailRequest{
   198  			RequestId: requestID.String(),
   199  			Sender:    m.sender(msg),
   200  			ReplyTo:   msg.ReplyTo,
   201  			To:        msg.To,
   202  			Cc:        msg.Cc,
   203  			Bcc:       msg.Bcc,
   204  			Subject:   msg.Subject,
   205  			TextBody:  msg.TextBody,
   206  			HtmlBody:  msg.HTMLBody,
   207  		})
   208  		if err != nil {
   209  			return grpcutil.WrapIfTransient(err)
   210  		}
   211  		logging.Infof(ctx, "Email enqueued as %q", resp.MessageId)
   212  		return nil
   213  	}, nil
   214  }
   215  
   216  func (m *mailerModule) initGAEMailer(ctx context.Context) (Mailer, error) {
   217  	return func(ctx context.Context, msg *Mail) error {
   218  		req := &gaemailpb.MailMessage{
   219  			Sender:  proto.String(m.sender(msg)),
   220  			To:      msg.To,
   221  			Cc:      msg.Cc,
   222  			Bcc:     msg.Bcc,
   223  			Subject: &msg.Subject,
   224  		}
   225  		if msg.ReplyTo != "" {
   226  			req.ReplyTo = &msg.ReplyTo
   227  		}
   228  		if msg.TextBody != "" {
   229  			req.TextBody = &msg.TextBody
   230  		}
   231  		if msg.HTMLBody != "" {
   232  			req.HtmlBody = &msg.HTMLBody
   233  		}
   234  
   235  		res := &gaebasepb.VoidProto{}
   236  		if err := gae.Call(ctx, "mail", "Send", req, res); err != nil {
   237  			// TODO(vadimsh): In theory we can extract internal GAE Mail error codes
   238  			// here and decide if an error is transient or not. For now assume they
   239  			// all are.
   240  			return transient.Tag.Apply(err)
   241  		}
   242  		logging.Infof(ctx, "Email enqueued")
   243  		return nil
   244  	}, nil
   245  }