diff --git a/controller.go b/controller.go index 24ad0e42..4e3103ee 100644 --- a/controller.go +++ b/controller.go @@ -24,6 +24,7 @@ import ( orgv1 "github.com/appuio/control-api/apis/organization/v1" userv1 "github.com/appuio/control-api/apis/user/v1" controlv1 "github.com/appuio/control-api/apis/v1" + "github.com/appuio/control-api/mailsenders" "github.com/appuio/control-api/controllers" "github.com/appuio/control-api/webhooks" @@ -58,6 +59,17 @@ func ControllerCommand() *cobra.Command { invTokenValidFor := cmd.Flags().Duration("invitation-valid-for", 30*24*time.Hour, "The duration an invitation token is valid for") redeemedInvitationTTL := cmd.Flags().Duration("redeemed-invitation-ttl", 30*24*time.Hour, "The duration for which a redeemed invitation is kept before deleting it") + invEmailBackend := cmd.Flags().String("email-backend", "stdout", "Backend to use for sending invitation mails (one of stdout, mailgun)") + invEmailSender := cmd.Flags().String("email-sender", "noreply@appuio.cloud", "Sender address for invitation mails") + invEmailSubject := cmd.Flags().String("email-subject", "You have been invited to APPUiO Cloud", "Subject for invitation mails") + invEmailRetryInterval := cmd.Flags().Duration("email-retry-interval", 5*time.Minute, "Retry interval for sending e-mail messages") + + invEmailMailgunToken := cmd.Flags().String("mailgun-token", "CHANGEME", "Token used to access Mailgun API") + invEmailMailgunDomain := cmd.Flags().String("mailgun-domain", "example.com", "Mailgun Domain to use") + invEmailMailgunTemplate := cmd.Flags().String("mailgun-template", "appuio-cloud-invitation", "Name of the Mailgun template") + invEmailMailgunUrl := cmd.Flags().String("mailgun-url", "https://api.eu.mailgun.net/v3", "API base URL for your Mailgun account") + invEmailMailgunTestMode := cmd.Flags().Bool("mailgun-test-mode", false, "If set, do not actually send e-mails") + cmd.Run = func(*cobra.Command, []string) { scheme := runtime.NewScheme() setupLog := ctrl.Log.WithName("setup") @@ -72,6 +84,22 @@ func ControllerCommand() *cobra.Command { ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) ctx := ctrl.SetupSignalHandler() + var mailSender mailsenders.MailSender + if *invEmailBackend == "mailgun" { + b := mailsenders.NewMailgunSender( + *invEmailMailgunDomain, + *invEmailMailgunToken, + *invEmailMailgunUrl, + *invEmailSender, + *invEmailMailgunTemplate, + *invEmailSubject, + *invEmailMailgunTestMode, + ) + mailSender = &b + } else { + mailSender = &mailsenders.LogSender{} + } + mgr, err := setupManager( *usernamePrefix, *rolePrefix, @@ -80,6 +108,8 @@ func ControllerCommand() *cobra.Command { *beRefreshJitter, *invTokenValidFor, *redeemedInvitationTTL, + *invEmailRetryInterval, + mailSender, ctrl.Options{ Scheme: scheme, MetricsBindAddress: *metricsAddr, @@ -104,7 +134,18 @@ func ControllerCommand() *cobra.Command { return cmd } -func setupManager(usernamePrefix, rolePrefix string, memberRoles []string, beRefreshInterval, beRefreshJitter, invTokenValidFor time.Duration, redeemedInvitationTTL time.Duration, opt ctrl.Options) (ctrl.Manager, error) { +func setupManager( + usernamePrefix, + rolePrefix string, + memberRoles []string, + beRefreshInterval, + beRefreshJitter, + invTokenValidFor time.Duration, + redeemedInvitationTTL time.Duration, + invEmailRetryInterval time.Duration, + mailSender mailsenders.MailSender, + opt ctrl.Options, +) (ctrl.Manager, error) { mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), opt) if err != nil { return nil, err @@ -177,6 +218,16 @@ func setupManager(usernamePrefix, rolePrefix string, memberRoles []string, beRef if err = invclean.SetupWithManager(mgr); err != nil { return nil, err } + invmail := &controllers.InvitationEmailReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: mgr.GetEventRecorderFor("invitation-cleanup-controller"), + RetryInterval: invEmailRetryInterval, + MailSender: mailSender, + } + if err = invmail.SetupWithManager(mgr); err != nil { + return nil, err + } mgr.GetWebhookServer().Register("/validate-appuio-io-v1-user", &webhook.Admission{ Handler: &webhooks.UserValidator{}, diff --git a/controllers/invitation_email_controller.go b/controllers/invitation_email_controller.go new file mode 100644 index 00000000..0d6e3775 --- /dev/null +++ b/controllers/invitation_email_controller.go @@ -0,0 +1,100 @@ +package controllers + +import ( + "context" + "fmt" + "time" + + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + + "github.com/appuio/control-api/mailsenders" + + userv1 "github.com/appuio/control-api/apis/user/v1" +) + +// InvitationEmailReconciler reconciles invitations and sends invitation emails if appropriate +type InvitationEmailReconciler struct { + client.Client + + Recorder record.EventRecorder + Scheme *runtime.Scheme + + MailSender mailsenders.MailSender + RetryInterval time.Duration +} + +//+kubebuilder:rbac:groups="rbac.appuio.io",resources=invitations,verbs=get;list;watch +//+kubebuilder:rbac:groups="user.appuio.io",resources=invitations,verbs=get;list;watch +//+kubebuilder:rbac:groups="rbac.appuio.io",resources=invitations/status,verbs=get;update;patch +//+kubebuilder:rbac:groups="user.appuio.io",resources=invitations/status,verbs=get;update;patch + +// Reconcile reacts to redeemed invitations and sends invitation emails to the user if needed. +func (r *InvitationEmailReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := log.FromContext(ctx) + log.V(1).WithValues("request", req).Info("Reconciling") + + inv := userv1.Invitation{} + if err := r.Get(ctx, req.NamespacedName, &inv); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + if !inv.ObjectMeta.DeletionTimestamp.IsZero() { + return ctrl.Result{}, nil + } + + if inv.Status.Token == "" { + return ctrl.Result{}, nil + } + + if apimeta.IsStatusConditionTrue(inv.Status.Conditions, userv1.ConditionEmailSent) { + return ctrl.Result{}, nil + } + condition := apimeta.FindStatusCondition(inv.Status.Conditions, userv1.ConditionEmailSent) + + if condition == nil { + return ctrl.Result{}, nil + } + + email := inv.Spec.Email + id, err := r.MailSender.Send(ctx, email, inv.Name, inv.Status.Token) + + if err != nil { + log.V(0).Error(err, "Error in e-mail backend") + + // Only update status if the error changes + if condition.Reason != err.Error() { + apimeta.SetStatusCondition(&inv.Status.Conditions, metav1.Condition{ + Type: userv1.ConditionEmailSent, + Status: metav1.ConditionFalse, + Reason: err.Error(), + }) + return ctrl.Result{}, r.Client.Status().Update(ctx, &inv) + } + return ctrl.Result{RequeueAfter: r.RetryInterval}, nil + } + + var message string + if id != "" { + message = fmt.Sprintf("Message ID: %s", id) + } + apimeta.SetStatusCondition(&inv.Status.Conditions, metav1.Condition{ + Type: userv1.ConditionEmailSent, + Status: metav1.ConditionTrue, + Message: message, + }) + + return ctrl.Result{}, r.Client.Status().Update(ctx, &inv) +} + +// SetupWithManager sets up the controller with the Manager. +func (r *InvitationEmailReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&userv1.Invitation{}). + Complete(r) +} diff --git a/controllers/invitation_email_controller_test.go b/controllers/invitation_email_controller_test.go new file mode 100644 index 00000000..98a37a36 --- /dev/null +++ b/controllers/invitation_email_controller_test.go @@ -0,0 +1,127 @@ +package controllers_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/require" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/client" + + userv1 "github.com/appuio/control-api/apis/user/v1" + . "github.com/appuio/control-api/controllers" +) + +type FailingSender struct{} +type SenderWithConstantId struct{} + +func (f *FailingSender) Send(context.Context, string, string, string) (string, error) { + return "", errors.New("Err0r") +} + +func (s *SenderWithConstantId) Send(context.Context, string, string, string) (string, error) { + return "ID10", nil +} + +func Test_InvitationEmailReconciler_Reconcile_Success(t *testing.T) { + ctx := context.Background() + + subject := &userv1.Invitation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "subject", + }, + Status: userv1.InvitationStatus{ + Token: "abc", + }, + } + apimeta.SetStatusCondition(&subject.Status.Conditions, metav1.Condition{ + Type: userv1.ConditionEmailSent, + Status: metav1.ConditionFalse, + }) + + c := prepareTest(t, subject) + + r := invitationEmailReconciler(c) + _, err := r.Reconcile(ctx, requestFor(subject)) + require.NoError(t, err) + + require.NoError(t, c.Get(ctx, client.ObjectKeyFromObject(subject), subject)) + require.True(t, apimeta.IsStatusConditionTrue(subject.Status.Conditions, userv1.ConditionEmailSent)) + condition := apimeta.FindStatusCondition(subject.Status.Conditions, userv1.ConditionEmailSent) + require.Equal(t, "Message ID: ID10", condition.Message) +} + +func Test_InvitationEmailReconciler_Reconcile_WithSendingFailure_Success(t *testing.T) { + ctx := context.Background() + + subject := &userv1.Invitation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "subject", + }, + Status: userv1.InvitationStatus{ + Token: "abc", + }, + } + apimeta.SetStatusCondition(&subject.Status.Conditions, metav1.Condition{ + Type: userv1.ConditionEmailSent, + Status: metav1.ConditionFalse, + }) + + c := prepareTest(t, subject) + + r := invitationEmailReconcilerWithFailingSender(c) + _, err := r.Reconcile(ctx, requestFor(subject)) + require.NoError(t, err) + + require.NoError(t, c.Get(ctx, client.ObjectKeyFromObject(subject), subject)) + require.False(t, apimeta.IsStatusConditionTrue(subject.Status.Conditions, userv1.ConditionEmailSent)) + condition := apimeta.FindStatusCondition(subject.Status.Conditions, userv1.ConditionEmailSent) + require.NotNil(t, condition) + require.Equal(t, "Err0r", condition.Reason) +} + +func Test_InvitationEmailReconciler_Reconcile_NoEmail_Success(t *testing.T) { + ctx := context.Background() + + subject := &userv1.Invitation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "subject", + }, + Status: userv1.InvitationStatus{ + Token: "abc", + }, + } + + c := prepareTest(t, subject) + + r := invitationEmailReconciler(c) + _, err := r.Reconcile(ctx, requestFor(subject)) + require.NoError(t, err) + + require.NoError(t, c.Get(ctx, client.ObjectKeyFromObject(subject), subject)) + require.Nil(t, apimeta.FindStatusCondition(subject.Status.Conditions, userv1.ConditionEmailSent)) +} + +func invitationEmailReconciler(c client.WithWatch) *InvitationEmailReconciler { + return &InvitationEmailReconciler{ + Client: c, + Scheme: c.Scheme(), + Recorder: record.NewFakeRecorder(3), + MailSender: &SenderWithConstantId{}, + RetryInterval: time.Minute, + } +} + +func invitationEmailReconcilerWithFailingSender(c client.WithWatch) *InvitationEmailReconciler { + return &InvitationEmailReconciler{ + Client: c, + Scheme: c.Scheme(), + Recorder: record.NewFakeRecorder(3), + MailSender: &FailingSender{}, + RetryInterval: time.Minute, + } +} diff --git a/go.mod b/go.mod index 078d47c8..f08c77f6 100644 --- a/go.mod +++ b/go.mod @@ -24,6 +24,8 @@ require ( sigs.k8s.io/kustomize/kustomize/v4 v4.5.7 ) +require github.com/gorilla/mux v1.8.0 // indirect + require ( github.com/BurntSushi/toml v1.2.1 // indirect github.com/NYTimes/gziphandler v1.1.1 // indirect @@ -66,6 +68,7 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/mailgun/mailgun-go/v4 v4.8.2 github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.17 // indirect diff --git a/go.sum b/go.sum index 14d1623e..7fb1a10d 100644 --- a/go.sum +++ b/go.sum @@ -111,6 +111,12 @@ github.com/evanphx/json-patch v5.6.0+incompatible h1:jBYDEEiFBPxA0v50tFdvOzQQTCv github.com/evanphx/json-patch v5.6.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.6.0 h1:b91NhWfaz02IuVxO9faSllyAtNXHMPkC5J8sJCLunww= github.com/evanphx/json-patch/v5 v5.6.0/go.mod h1:G79N1coSVB93tBe7j6PhzjmR3/2VvlbKOFpnXhI9Bw4= +github.com/facebookgo/ensure v0.0.0-20160127193407-b4ab57deab51 h1:0JZ+dUmQeA8IIVUMzysrX4/AKuQwWhV2dYQuPZdvdSQ= +github.com/facebookgo/ensure v0.0.0-20160127193407-b4ab57deab51/go.mod h1:Yg+htXGokKKdzcwhuNDwVvN+uBxDGXJ7G/VN1d8fa64= +github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 h1:JWuenKqqX8nojtoVVWjGfOF9635RETekkoH6Cc9SX0A= +github.com/facebookgo/stack v0.0.0-20160209184415-751773369052/go.mod h1:UbMTZqLaRiH3MsBH8va0n7s1pQYcu3uTb8G4tygF4Zg= +github.com/facebookgo/subset v0.0.0-20150612182917-8dac2c3c4870 h1:E2s37DuLxFhQDg5gKsWoLBOB0n+ZW8s599zru8FJ2/Y= +github.com/facebookgo/subset v0.0.0-20150612182917-8dac2c3c4870/go.mod h1:5tD+neXqOorC30/tWg0LCSkrqj/AR6gu8yY8/fpw1q0= github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk= @@ -228,6 +234,8 @@ github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 h1:+9834+KizmvFV7pXQGSXQTsaWhq2GjuNUt0aUU0YBYw= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho= @@ -273,6 +281,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailgun/mailgun-go/v4 v4.8.2 h1:52uaKBnTAaVtKFGtLwUSnpUzQKX1z8RfwYsBaON0l5s= +github.com/mailgun/mailgun-go/v4 v4.8.2/go.mod h1:FJlF9rI5cQT+mrwujtJjPMbIVy3Ebor9bKTVsJ0QU40= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= diff --git a/mailsenders/mail_senders.go b/mailsenders/mail_senders.go new file mode 100644 index 00000000..dcaa680c --- /dev/null +++ b/mailsenders/mail_senders.go @@ -0,0 +1,66 @@ +package mailsenders + +import ( + "context" + + "github.com/mailgun/mailgun-go/v4" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +type MailSender interface { + Send(ctx context.Context, recipient string, invitation string, token string) (string, error) +} + +type MailgunSender struct { + Mailgun *mailgun.MailgunImpl + MailgunBaseUrl string + SenderAddress string + TemplateName string + UseTestMode bool + Subject string +} + +type LogSender struct{} + +func (s *LogSender) Send(ctx context.Context, recipient string, invitation string, token string) (string, error) { + log := log.FromContext(ctx) + log.V(0).Info("E-mail backend is 'stdout'; invitation e-mail was not sent", "recipient", recipient, "invitation", invitation) + return "", nil +} + +func NewMailgunSender(domain string, token string, baseUrl string, senderAddress string, templateName string, subject string, useTestMode bool) MailgunSender { + mg := mailgun.NewMailgun(domain, token) + mg.SetAPIBase(baseUrl) + return MailgunSender{ + Mailgun: mg, + SenderAddress: senderAddress, + TemplateName: templateName, + UseTestMode: useTestMode, + Subject: subject, + } +} + +func (m *MailgunSender) Send(ctx context.Context, recipient string, invitation string, token string) (string, error) { + message := m.Mailgun.NewMessage( + m.SenderAddress, + m.Subject, + "", // Message body will be rendered from template + recipient, + ) + message.SetTemplate(m.TemplateName) + err := message.AddTemplateVariable("invitation", invitation) + if err != nil { + return "", err + } + err = message.AddTemplateVariable("token", token) + if err != nil { + return "", err + } + + if m.UseTestMode { + message.EnableTestMode() + } + + _, id, err := m.Mailgun.Send(ctx, message) + return id, err +}