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 }