Skip to content

Commit

Permalink
Merge pull request #116 from appuio/feat/invitation-emails
Browse files Browse the repository at this point in the history
Add controller for invitation e-mail sending
  • Loading branch information
HappyTetrahedron authored Mar 6, 2023
2 parents 794cea3 + 9c09d40 commit 6b29c11
Show file tree
Hide file tree
Showing 6 changed files with 358 additions and 1 deletion.
53 changes: 52 additions & 1 deletion controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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", "[email protected]", "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")
Expand All @@ -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,
Expand All @@ -80,6 +108,8 @@ func ControllerCommand() *cobra.Command {
*beRefreshJitter,
*invTokenValidFor,
*redeemedInvitationTTL,
*invEmailRetryInterval,
mailSender,
ctrl.Options{
Scheme: scheme,
MetricsBindAddress: *metricsAddr,
Expand All @@ -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
Expand Down Expand Up @@ -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{},
Expand Down
100 changes: 100 additions & 0 deletions controllers/invitation_email_controller.go
Original file line number Diff line number Diff line change
@@ -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)
}
127 changes: 127 additions & 0 deletions controllers/invitation_email_controller_test.go
Original file line number Diff line number Diff line change
@@ -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,
}
}
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
Loading

0 comments on commit 6b29c11

Please sign in to comment.