From 4f7484a8eaf5b22001806a7b781befd68eeda3fc Mon Sep 17 00:00:00 2001 From: RonniSkansing Date: Wed, 7 Oct 2026 20:06:57 +0200 Subject: [PATCH] added API Sender test variables Signed-off-by: RonniSkansing --- backend/controller/apiSender.go | 6 + backend/controller/utils.go | 23 +++ backend/model/apiSender.go | 19 ++ backend/service/apiSender.go | 168 +++++++++++++++-- frontend/src/lib/api/api.js | 5 +- frontend/src/routes/api-sender/+page.svelte | 195 ++++++++++++++++++-- 6 files changed, 384 insertions(+), 32 deletions(-) diff --git a/backend/controller/apiSender.go b/backend/controller/apiSender.go index 88394bcb..9d6b66c5 100644 --- a/backend/controller/apiSender.go +++ b/backend/controller/apiSender.go @@ -182,10 +182,16 @@ func (a *APISender) SendTest(g *gin.Context) { if !ok { return } + // parse the optional test values, an empty body means use the defaults + var req model.APISenderTestRequest + if ok := a.handleParseOptionalRequest(g, &req); !ok { + return + } data, err := a.APISenderService.SendTest( g.Request.Context(), session, id, + &req, ) // output the error if err != nil { diff --git a/backend/controller/utils.go b/backend/controller/utils.go index 97a7a349..f42cb7a7 100644 --- a/backend/controller/utils.go +++ b/backend/controller/utils.go @@ -83,6 +83,26 @@ func (c *Common) handleGlobalAuthorization( func (c *Common) handleParseRequest( g *gin.Context, req any, +) bool { + return c.parseRequest(g, req, false) +} + +// handleParseOptionalRequest parses a request body like handleParseRequest but +// treats an empty body as no input, leaving req at its zero value. Use it for +// endpoints where every field is optional. +func (c *Common) handleParseOptionalRequest( + g *gin.Context, + req any, +) bool { + return c.parseRequest(g, req, true) +} + +// parseRequest reads and unmarshals the request body. When allowEmpty is true an +// empty body is accepted and leaves req untouched. +func (c *Common) parseRequest( + g *gin.Context, + req any, + allowEmpty bool, ) bool { body, err := io.ReadAll(g.Request.Body) if err != nil { @@ -92,6 +112,9 @@ func (c *Common) handleParseRequest( c.Response.BadRequest(g) return false } + if allowEmpty && len(bytes.TrimSpace(body)) == 0 { + return true + } if err := utils.Unmarshal(body, &req); err != nil { c.Logger.Debugw("failed to parse request", "error", err, diff --git a/backend/model/apiSender.go b/backend/model/apiSender.go index 2968cc8d..04a8d1a0 100644 --- a/backend/model/apiSender.go +++ b/backend/model/apiSender.go @@ -37,6 +37,25 @@ type APISender struct { ExpectedResponseBody nullable.Nullable[vo.OptionalString1MB] `json:"expectedResponseBody"` } +// APISenderTestRequest holds optional values the user sets when running a +// test send. A nil or empty field falls back to the built in default. +type APISenderTestRequest struct { + Email *string `json:"email"` + FirstName *string `json:"firstName"` + LastName *string `json:"lastName"` + Subject *string `json:"subject"` + Content *string `json:"content"` + FromEmail *string `json:"fromEmail"` + FromName *string `json:"fromName"` + Phone *string `json:"phone"` + ExtraIdentifier *string `json:"extraIdentifier"` + Position *string `json:"position"` + Department *string `json:"department"` + City *string `json:"city"` + Country *string `json:"country"` + Misc *string `json:"misc"` +} + // Validate checks if the API sender has a valid state func (a *APISender) Validate() error { if err := validate.NullableFieldRequired("name", a.Name); err != nil { diff --git a/backend/service/apiSender.go b/backend/service/apiSender.go index 51a0741b..253adfe8 100644 --- a/backend/service/apiSender.go +++ b/backend/service/apiSender.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "net/mail" "strings" "text/template" "time" @@ -400,6 +401,7 @@ func (a *APISender) SendTest( ctx context.Context, session *model.Session, id *uuid.UUID, + req *model.APISenderTestRequest, ) (*APISenderTestResponse, error) { ae := NewAuditEvent("ApiSender.SendTest", session) ae.Details["id"] = id.String() @@ -436,26 +438,156 @@ func (a *APISender) SendTest( oauthAccessToken = token a.Logger.Debugw("got oauth access token for api test request", "oauthProviderID", oauthProviderID) } - emailRaw := "bob@enterprise.test" - email := *vo.NewEmailMust(emailRaw) + // resolve the test values, a nil or empty override falls back to the default + if req == nil { + req = &model.APISenderTestRequest{} + } + const ( + defFromEmail = "bob@enterprise.test" + defFromName = "Bob" + defEmail = "bob@enterprise.test" + defFirstName = "Bob" + defLastName = "Test" + defSubject = "Test Email Subject" + defContent = "Hi {{.FirstName}},\n\nThis is a test email.\n\nBest,\nBob" + defPhone = "+1234567890" + defExtraID = "extra-test-identifier" + defPosition = "Lead API Tester" + defDept = "Research and Development" + defCity = "Odin" + defCountry = "Denmark" + defMisc = "This is a test recipient" + ) + pick := func(p *string, def string) string { + if p != nil && *p != "" { + return *p + } + return def + } + // trim the from fields, they are composed into one header below where + // surrounding whitespace would otherwise fail validation + fromEmailRaw := strings.TrimSpace(pick(req.FromEmail, defFromEmail)) + fromNameRaw := strings.TrimSpace(pick(req.FromName, defFromName)) + recipientEmailRaw := pick(req.Email, defEmail) + firstNameRaw := pick(req.FirstName, defFirstName) + lastNameRaw := pick(req.LastName, defLastName) + subjectRaw := pick(req.Subject, defSubject) + contentRaw := pick(req.Content, defContent) + phoneRaw := pick(req.Phone, defPhone) + extraIdentifierRaw := pick(req.ExtraIdentifier, defExtraID) + positionRaw := pick(req.Position, defPosition) + departmentRaw := pick(req.Department, defDept) + cityRaw := pick(req.City, defCity) + countryRaw := pick(req.Country, defCountry) + miscRaw := pick(req.Misc, defMisc) + + recipientEmail, err := vo.NewEmail(recipientEmailRaw) + if err != nil { + return nil, errs.Wrap(fmt.Errorf("email: %w", err)) + } + envelopeFrom, err := vo.NewMailEnvelopeFrom(fromEmailRaw) + if err != nil { + return nil, errs.Wrap(fmt.Errorf("from email: %w", err)) + } + // build the header via mail.Address so a display name with a comma or other + // special character is quoted correctly, fromEmail is already validated above + headerFromRaw := (&mail.Address{Name: fromNameRaw, Address: fromEmailRaw}).String() + headerFrom, err := vo.NewEmail(headerFromRaw) + if err != nil { + return nil, errs.Wrap(fmt.Errorf("from name or from email: %w", err)) + } + subject, err := vo.NewOptionalString255(subjectRaw) + if err != nil { + return nil, errs.Wrap(fmt.Errorf("subject: %w", err)) + } + content, err := vo.NewOptionalString1MB(contentRaw) + if err != nil { + return nil, errs.Wrap(fmt.Errorf("content: %w", err)) + } + firstName, err := vo.NewOptionalString127(firstNameRaw) + if err != nil { + return nil, errs.Wrap(fmt.Errorf("first name: %w", err)) + } + lastName, err := vo.NewOptionalString127(lastNameRaw) + if err != nil { + return nil, errs.Wrap(fmt.Errorf("last name: %w", err)) + } + phone, err := vo.NewOptionalString127(phoneRaw) + if err != nil { + return nil, errs.Wrap(fmt.Errorf("phone: %w", err)) + } + extraIdentifier, err := vo.NewOptionalString127(extraIdentifierRaw) + if err != nil { + return nil, errs.Wrap(fmt.Errorf("extra identifier: %w", err)) + } + position, err := vo.NewOptionalString127(positionRaw) + if err != nil { + return nil, errs.Wrap(fmt.Errorf("position: %w", err)) + } + department, err := vo.NewOptionalString127(departmentRaw) + if err != nil { + return nil, errs.Wrap(fmt.Errorf("department: %w", err)) + } + city, err := vo.NewOptionalString127(cityRaw) + if err != nil { + return nil, errs.Wrap(fmt.Errorf("city: %w", err)) + } + country, err := vo.NewOptionalString127(countryRaw) + if err != nil { + return nil, errs.Wrap(fmt.Errorf("country: %w", err)) + } + misc, err := vo.NewOptionalString127(miscRaw) + if err != nil { + return nil, errs.Wrap(fmt.Errorf("misc: %w", err)) + } + + // record which values differ from the default, names only to avoid logging content + isOverride := func(p *string, def string) bool { + return p != nil && strings.TrimSpace(*p) != "" && strings.TrimSpace(*p) != def + } + overridden := []string{} + for _, f := range []struct { + name string + p *string + def string + }{ + {"fromEmail", req.FromEmail, defFromEmail}, + {"fromName", req.FromName, defFromName}, + {"email", req.Email, defEmail}, + {"firstName", req.FirstName, defFirstName}, + {"lastName", req.LastName, defLastName}, + {"subject", req.Subject, defSubject}, + {"content", req.Content, defContent}, + {"phone", req.Phone, defPhone}, + {"extraIdentifier", req.ExtraIdentifier, defExtraID}, + {"position", req.Position, defPosition}, + {"department", req.Department, defDept}, + {"city", req.City, defCity}, + {"country", req.Country, defCountry}, + {"misc", req.Misc, defMisc}, + } { + if isOverride(f.p, f.def) { + overridden = append(overridden, f.name) + } + } + ae.Details["overridden"] = strings.Join(overridden, ",") + cid := nullable.NewNullableWithValue(uuid.New()) testEmail := &model.Email{ Name: nullable.NewNullableWithValue( *vo.NewString64Must("Test Email"), ), MailEnvelopeFrom: nullable.NewNullableWithValue( - *vo.NewMailEnvelopeFromMust(emailRaw), + *envelopeFrom, ), MailHeaderFrom: nullable.NewNullableWithValue( - *vo.NewEmailMust( - fmt.Sprintf("Bob <%s>", emailRaw), - ), + *headerFrom, ), MailHeaderSubject: nullable.NewNullableWithValue( - *vo.NewOptionalString255Must("Test Email Subject"), + *subject, ), Content: nullable.NewNullableWithValue( - *vo.NewOptionalString1MBMust("Hi {{.FirstName}},\n\nThis is a test email.\n\nBest,\nBob"), + *content, ), AddTrackingPixel: nullable.NewNullableWithValue(false), } @@ -464,34 +596,34 @@ func (a *APISender) SendTest( Recipient: &model.Recipient{ ID: cid, Email: nullable.NewNullableWithValue( - email, + *recipientEmail, ), Phone: nullable.NewNullableWithValue( - *vo.NewOptionalString127Must("+1234567890"), + *phone, ), ExtraIdentifier: nullable.NewNullableWithValue( - *vo.NewOptionalString127Must("extra-test-identifier"), + *extraIdentifier, ), FirstName: nullable.NewNullableWithValue( - *vo.NewOptionalString127Must("Bob"), + *firstName, ), LastName: nullable.NewNullableWithValue( - *vo.NewOptionalString127Must("Test"), + *lastName, ), Position: nullable.NewNullableWithValue( - *vo.NewOptionalString127Must("Lead API Tester"), + *position, ), Department: nullable.NewNullableWithValue( - *vo.NewOptionalString127Must("Research and Development"), + *department, ), City: nullable.NewNullableWithValue( - *vo.NewOptionalString127Must("Odin"), + *city, ), Country: nullable.NewNullableWithValue( - *vo.NewOptionalString127Must("Denmark"), + *country, ), Misc: nullable.NewNullableWithValue( - *vo.NewOptionalString127Must("This is a test recipient"), + *misc, ), Company: &model.Company{ Name: nullable.NewNullableWithValue( diff --git a/frontend/src/lib/api/api.js b/frontend/src/lib/api/api.js index 9325b012..acc36667 100644 --- a/frontend/src/lib/api/api.js +++ b/frontend/src/lib/api/api.js @@ -3203,10 +3203,11 @@ export class API { /** * Send a test request to an API sender. * @param {string} id + * @param {object} [values] optional test values, empty fields use defaults * @returns {Promise} */ - test: async (id) => { - return await postJSON(this.getPath(`/api-sender/${id}/test`)); + test: async (id, values = {}) => { + return await postJSON(this.getPath(`/api-sender/${id}/test`), values); } }; diff --git a/frontend/src/routes/api-sender/+page.svelte b/frontend/src/routes/api-sender/+page.svelte index 6a0a22cc..94571e01 100644 --- a/frontend/src/routes/api-sender/+page.svelte +++ b/frontend/src/routes/api-sender/+page.svelte @@ -43,6 +43,7 @@ import { fetchAllRows } from '$lib/utils/api-utils'; import TextFieldSelect from '$lib/components/TextFieldSelect.svelte'; import VimToggle from '$lib/components/editor/VimToggle.svelte'; + import FormButton from '$lib/components/FormButton.svelte'; // services const appStateService = AppStateService.instance; @@ -107,6 +108,68 @@ let testResponse = {}; let isTestModalVisible = false; + let isTestSubmitting = false; + let selectedTestID = null; + + // defaults mirror the backend test defaults, empty fields fall back to them + const testValuesDefault = () => ({ + fromEmail: 'bob@enterprise.test', + fromName: 'Bob', + email: 'bob@enterprise.test', + firstName: 'Bob', + lastName: 'Test', + subject: 'Test Email Subject', + content: 'Hi {{.FirstName}},\n\nThis is a test email.\n\nBest,\nBob' + }); + let testValues = testValuesDefault(); + + // optional recipient variables the user can add to a test on demand + const extraVariableDefs = [ + { key: 'phone', label: 'Phone', variable: '{{.Phone}}', value: '+1234567890' }, + { + key: 'extraIdentifier', + label: 'Extra Identifier', + variable: '{{.ExtraIdentifier}}', + value: 'extra-test-identifier' + }, + { key: 'position', label: 'Position', variable: '{{.Position}}', value: 'Lead API Tester' }, + { + key: 'department', + label: 'Department', + variable: '{{.Department}}', + value: 'Research and Development' + }, + { key: 'city', label: 'City', variable: '{{.City}}', value: 'Odin' }, + { key: 'country', label: 'Country', variable: '{{.Country}}', value: 'Denmark' }, + { key: 'misc', label: 'Misc', variable: '{{.Misc}}', value: 'This is a test recipient' } + ]; + let extraOverrides = []; + let selectedExtra = ''; + // remembers an edited value so a removed variable keeps it when added again + let extraValueCache = {}; + $: availableExtras = extraVariableDefs + .filter((d) => !extraOverrides.some((o) => o.key === d.key)) + .map((d) => ({ value: d.key, label: d.label })); + + /** @param {string} key */ + const addExtra = (key) => { + const def = extraVariableDefs.find((d) => d.key === key); + if (!def || extraOverrides.some((o) => o.key === key)) { + return; + } + const value = key in extraValueCache ? extraValueCache[key] : def.value; + extraOverrides = [...extraOverrides, { ...def, value }]; + selectedExtra = ''; + }; + + /** @param {string} key */ + const removeExtra = (key) => { + const existing = extraOverrides.find((o) => o.key === key); + if (existing) { + extraValueCache[key] = existing.value; + } + extraOverrides = extraOverrides.filter((o) => o.key !== key); + }; $: { modalText = getModalText('api sender', modalMode); @@ -318,32 +381,44 @@ }; /** @param {string} id */ - const openTestModal = async (id) => { + const openTestModal = (id) => { + selectedTestID = id; + testResponse = {}; + testValues = testValuesDefault(); + extraOverrides = []; + extraValueCache = {}; + selectedExtra = ''; + isTestModalVisible = true; + }; + + const runTest = async () => { try { - showIsLoading(); - const res = await api.apiSender.test(id); + isTestSubmitting = true; + const payload = { ...testValues }; + for (const o of extraOverrides) { + payload[o.key] = o.value; + } + const res = await api.apiSender.test(selectedTestID, payload); if (!res.success) { - const res2 = await api.apiSender.getByID(id); + const res2 = await api.apiSender.getByID(selectedTestID); if (!res2.success) { throw res2.error; } - testResponse.apiSender = res2.data; - testResponse.error = res.error; - isTestModalVisible = true; + testResponse = { apiSender: res2.data, error: res.error }; return; } testResponse = res.data; - isTestModalVisible = true; } catch (e) { addToast('Failed to test API sender', 'Error'); console.error('failed to test API sender:', e); } finally { - hideIsLoading(); + isTestSubmitting = false; } }; const closeTestModal = () => { testResponse = {}; + selectedTestID = null; isTestModalVisible = false; }; @@ -664,11 +739,106 @@ X-Custom-Header: Hello Friend" - +
- {#if !testResponse.error} + +
+
+

Test values

+
+ From Email + From Name + Email + First Name + Last Name + Subject +
+
+ Content +
+ +
+ {#each extraOverrides as extra (extra.key)} +
+
+ {extra.label} +
+ +
+ {/each} + {#if availableExtras.length > 0} + Add variable + {/if} +
+
+ Run test +
+
+
+ {#if testResponse.request || testResponse.error} + {#if !testResponse.error}

@@ -819,6 +989,7 @@ X-Custom-Header: Hello Friend"
{testResponse.error}

+ {/if} {/if}