added API Sender test variables

Signed-off-by: RonniSkansing <rskansing@gmail.com>
This commit is contained in:
RonniSkansing committed 2026-10-07 20:06:57 +02:00
1 parent 8580530c34
commit 4f7484a8ea
6 files changed
+384 -32

No files matched your search

+6
View File
@@ -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 {
+23
View File
@@ -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,
+19
View File
@@ -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 {
+150 -18
View File
@@ -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(
+3 -2
View File
@@ -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<ApiResponse>}
*/
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);
}
};
+183 -12
View File
@@ -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"
</FormGrid>
</Modal>
<Modal headerText="API Sender Test Results" visible={isTestModalVisible} onClose={closeTestModal}>
<Modal headerText="API Sender Test" visible={isTestModalVisible} onClose={closeTestModal}>
<div
class="col-span-3 w-full overflow-y-auto px-6 py-4 space-y-6 select-text overflow-x-hidden"
class="col-span-3 w-[800px] max-w-full overflow-y-auto px-6 py-4 space-y-6 select-text overflow-x-hidden"
>
{#if !testResponse.error}
<!-- Test values, empty fields fall back to the defaults -->
<form on:submit|preventDefault={runTest}>
<div class="pt-4 pb-2 w-full">
<h3 class="text-base font-medium text-pc-darkblue dark:text-white mb-3">Test values</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<TextField
type="email"
width="full"
maxLength={254}
bind:value={testValues.fromEmail}
toolTipText={'Sent as {{.FromEmail}}'}
placeholder="bob@enterprise.test">From Email</TextField
>
<TextField
width="full"
maxLength={128}
bind:value={testValues.fromName}
toolTipText={'Sent as {{.FromName}}'}
placeholder="Bob">From Name</TextField
>
<TextField
type="email"
width="full"
maxLength={254}
bind:value={testValues.email}
toolTipText={'Sent as {{.Email}}'}
placeholder="bob@enterprise.test">Email</TextField
>
<TextField
width="full"
maxLength={127}
bind:value={testValues.firstName}
toolTipText={'Sent as {{.FirstName}}'}
placeholder="Bob">First Name</TextField
>
<TextField
width="full"
maxLength={127}
bind:value={testValues.lastName}
toolTipText={'Sent as {{.LastName}}'}
placeholder="Test">Last Name</TextField
>
<TextField
width="full"
maxLength={255}
bind:value={testValues.subject}
toolTipText={'Sent as {{.Subject}}'}
placeholder="Test Email Subject">Subject</TextField
>
</div>
<div class="mt-4">
<TextareaField
fullWidth
height={'medium'}
bind:value={testValues.content}
toolTipText={'Sent as {{.Content}}'}>Content</TextareaField
>
</div>
<!-- optional recipient variables added on demand -->
<div class="mt-4 space-y-3">
{#each extraOverrides as extra (extra.key)}
<div class="flex items-end gap-2">
<div class="flex-1">
<TextField
width="full"
maxLength={127}
bind:value={extra.value}
toolTipText={'Sent as ' + extra.variable}>{extra.label}</TextField
>
</div>
<button
type="button"
class="mb-3 px-3 py-2 text-sm font-medium text-gray-600 dark:text-gray-300 hover:text-red-600"
on:click={() => removeExtra(extra.key)}
aria-label={'Remove ' + extra.label}>Remove</button
>
</div>
{/each}
{#if availableExtras.length > 0}
<TextFieldSelect
id="addTestVariable"
placeholder="Add another variable..."
bind:value={selectedExtra}
options={availableExtras}
optional={true}
onSelect={addExtra}>Add variable</TextFieldSelect
>
{/if}
</div>
<div class="flex justify-end mt-4">
<FormButton isSubmitting={isTestSubmitting}>Run test</FormButton>
</div>
</div>
</form>
{#if testResponse.request || testResponse.error}
{#if !testResponse.error}
<!-- Successful Test -->
<div class="mb-6 pt-4 pb-2 border-b border-gray-200 dark:border-gray-600 w-full">
<h3 class="text-base font-medium text-pc-darkblue dark:text-white mb-3">
@@ -819,6 +989,7 @@ X-Custom-Header: Hello Friend"
<div class="text-red-600 whitespace-pre-wrap break-words">{testResponse.error}</div>
</div>
</div>
{/if}
{/if}
<!-- Footer with Close Button -->