Send mailing list emails

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-03-05 18:05:41 +01:00
parent aa01c40184
commit 85ec106cd6
67 changed files with 7615 additions and 160 deletions

View File

@@ -31,6 +31,9 @@ type (
ID gid.GID `db:"id"`
RecipientEmail string `db:"recipient_email"`
RecipientName string `db:"recipient_name"`
ReplyTo *mail.Addr `db:"reply_to"`
UnsubscribeURL *string `db:"unsubscribe_url"`
MailingListUpdateID *gid.GID `db:"mailing_list_update_id"`
Subject string `db:"subject"`
TextBody string `db:"text_body"`
HtmlBody *string `db:"html_body"`
@@ -44,6 +47,14 @@ type (
UpdatedAt time.Time `db:"updated_at"`
SentAt *time.Time `db:"sent_at"`
}
Emails []*Email
EmailOptions struct {
ReplyTo *mail.Addr
UnsubscribeURL *string
MailingListUpdateID *gid.GID
}
)
var (
@@ -62,9 +73,10 @@ func NewEmail(
subject string,
textBody string,
htmlBody *string,
opts *EmailOptions,
) *Email {
now := time.Now()
return &Email{
e := &Email{
ID: gid.New(gid.NilTenant, EmailEntityType),
RecipientName: recipientName,
RecipientEmail: recipientEmail.String(),
@@ -77,6 +89,14 @@ func NewEmail(
CreatedAt: now,
UpdatedAt: now,
}
if opts != nil {
e.ReplyTo = opts.ReplyTo
e.UnsubscribeURL = opts.UnsubscribeURL
e.MailingListUpdateID = opts.MailingListUpdateID
}
return e
}
func (e *Email) Insert(
@@ -85,40 +105,100 @@ func (e *Email) Insert(
) error {
q := `
INSERT INTO emails (
id, recipient_email, recipient_name, subject, text_body, html_body,
status, attempt_count, max_attempts, created_at, updated_at
id,
recipient_email,
recipient_name,
reply_to, unsubscribe_url,
mailing_list_update_id,
subject,
text_body,
html_body,
status,
attempt_count,
max_attempts,
created_at,
updated_at
)
VALUES (
@id, @recipient_email, @recipient_name, @subject, @text_body, @html_body,
@status, @attempt_count, @max_attempts, @created_at, @updated_at
@id,
@recipient_email,
@recipient_name,
@reply_to,
@unsubscribe_url,
@mailing_list_update_id,
@subject,
@text_body,
@html_body,
@status,
@attempt_count,
@max_attempts,
@created_at,
@updated_at
)
`
`
args := pgx.StrictNamedArgs{
"id": e.ID,
"recipient_email": e.RecipientEmail,
"recipient_name": e.RecipientName,
"subject": e.Subject,
"text_body": e.TextBody,
"html_body": e.HtmlBody,
"status": e.Status,
"attempt_count": e.AttemptCount,
"max_attempts": e.MaxAttempts,
"created_at": e.CreatedAt,
"updated_at": e.UpdatedAt,
"id": e.ID,
"recipient_email": e.RecipientEmail,
"recipient_name": e.RecipientName,
"reply_to": e.ReplyTo,
"unsubscribe_url": e.UnsubscribeURL,
"mailing_list_update_id": e.MailingListUpdateID,
"subject": e.Subject,
"text_body": e.TextBody,
"html_body": e.HtmlBody,
"status": e.Status,
"attempt_count": e.AttemptCount,
"max_attempts": e.MaxAttempts,
"created_at": e.CreatedAt,
"updated_at": e.UpdatedAt,
}
_, err := conn.Exec(ctx, q, args)
return err
}
func (emails Emails) BulkInsert(
ctx context.Context,
conn pg.Conn,
) error {
if len(emails) == 0 {
return nil
}
rows := make([][]any, 0, len(emails))
for _, e := range emails {
rows = append(rows, []any{
e.ID,
e.RecipientEmail,
e.RecipientName,
e.ReplyTo,
e.UnsubscribeURL,
e.MailingListUpdateID,
e.Subject,
e.TextBody,
e.HtmlBody,
e.CreatedAt,
e.UpdatedAt,
})
}
_, err := conn.CopyFrom(
ctx,
pgx.Identifier{"emails"},
[]string{"id", "recipient_email", "recipient_name", "reply_to", "unsubscribe_url", "mailing_list_update_id", "subject", "text_body", "html_body", "created_at", "updated_at"},
pgx.CopyFromRows(rows),
)
return err
}
func (e *Email) LoadNextPendingForUpdateSkipLocked(
ctx context.Context,
conn pg.Conn,
) error {
q := `
SELECT
id, recipient_email, recipient_name, subject, text_body, html_body,
id, recipient_email, recipient_name, reply_to, unsubscribe_url, mailing_list_update_id, subject, text_body, html_body,
status, processing_started_at, attempt_count, max_attempts,
last_attempted_at, last_error, created_at, updated_at, sent_at
FROM emails

View File

@@ -89,6 +89,7 @@ const (
ComplianceExternalURLEntityType uint16 = 63
MailingListEntityType uint16 = 64
MailingListSubscriberEntityType uint16 = 65
MailingListUpdateEntityType uint16 = 66
)
func NewEntityFromID(id gid.GID) (any, bool) {
@@ -221,6 +222,8 @@ func NewEntityFromID(id gid.GID) (any, bool) {
return &MailingList{ID: id}, true
case MailingListSubscriberEntityType:
return &MailingListSubscriber{ID: id}, true
case MailingListUpdateEntityType:
return &MailingListUpdate{ID: id}, true
default:
return nil, false
}

View File

@@ -67,6 +67,56 @@ func (cns *MailingListSubscriber) CursorKey(orderBy MailingListSubscriberOrderFi
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
}
func (cns *MailingListSubscriber) LoadByID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
id gid.GID,
) error {
q := `
SELECT
id,
organization_id,
mailing_list_id,
full_name,
email,
status,
created_at,
updated_at
FROM
mailing_list_subscribers
WHERE
%s
AND id = @id
LIMIT 1;
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"id": id,
}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query mailing list subscriber: %w", err)
}
subscriber, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[MailingListSubscriber])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect mailing list subscriber: %w", err)
}
*cns = subscriber
return nil
}
func (cns *MailingListSubscriber) LoadByMailingListIDAndEmail(
ctx context.Context,
conn pg.Conn,
@@ -227,11 +277,15 @@ WHERE
args := pgx.StrictNamedArgs{"mailing_list_subscriber_id": cns.ID}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, q, args)
tag, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot delete mailing list subscriber: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrResourceNotFound
}
return nil
}
@@ -267,6 +321,50 @@ WHERE
return count, nil
}
func (cnss *MailingListSubscribers) LoadAllConfirmedByMailingListID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
mailingListID gid.GID,
) error {
q := `
SELECT
id,
organization_id,
mailing_list_id,
full_name,
email,
status,
created_at,
updated_at
FROM
mailing_list_subscribers
WHERE
%s
AND mailing_list_id = @mailing_list_id
AND status = 'CONFIRMED'
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"mailing_list_id": mailingListID}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query confirmed mailing list subscribers: %w", err)
}
subscribers, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[MailingListSubscriber])
if err != nil {
return fmt.Errorf("cannot collect confirmed mailing list subscribers: %w", err)
}
*cnss = subscribers
return nil
}
func (cnss *MailingListSubscribers) LoadByMailingListID(
ctx context.Context,
conn pg.Conn,

View File

@@ -0,0 +1,385 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package coredata
import (
"context"
"errors"
"fmt"
"maps"
"time"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
MailingListUpdate struct {
ID gid.GID `db:"id"`
OrganizationID gid.GID `db:"organization_id"`
MailingListID gid.GID `db:"mailing_list_id"`
Title string `db:"title"`
Body string `db:"body"`
Status MailingListUpdateStatus `db:"status"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
MailingListUpdateItems []*MailingListUpdate
)
func (mlu *MailingListUpdate) CursorKey(orderBy MailingListUpdateOrderField) page.CursorKey {
switch orderBy {
case MailingListUpdateOrderFieldCreatedAt:
return page.NewCursorKey(mlu.ID, mlu.CreatedAt)
case MailingListUpdateOrderFieldUpdatedAt:
return page.NewCursorKey(mlu.ID, mlu.UpdatedAt)
}
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
}
func (mlu *MailingListUpdate) AuthorizationAttributes(ctx context.Context, conn pg.Conn) (map[string]string, error) {
q := `SELECT organization_id FROM mailing_list_updates WHERE id = $1 LIMIT 1;`
var organizationID gid.GID
if err := conn.QueryRow(ctx, q, mlu.ID).Scan(&organizationID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrResourceNotFound
}
return nil, fmt.Errorf("cannot query mailing list update authorization attributes: %w", err)
}
return map[string]string{"organization_id": organizationID.String()}, nil
}
func (mlu *MailingListUpdate) Insert(ctx context.Context, conn pg.Conn, scope Scoper) error {
q := `
INSERT INTO mailing_list_updates (
id,
tenant_id,
organization_id,
mailing_list_id,
title,
body,
status,
created_at,
updated_at
) VALUES (
@id,
@tenant_id,
@organization_id,
@mailing_list_id,
@title,
@body,
@status,
@created_at,
@updated_at
)
`
args := pgx.StrictNamedArgs{
"id": mlu.ID,
"organization_id": mlu.OrganizationID,
"mailing_list_id": mlu.MailingListID,
"title": mlu.Title,
"body": mlu.Body,
"status": mlu.Status,
"created_at": mlu.CreatedAt,
"updated_at": mlu.UpdatedAt,
}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, q, args)
return err
}
func (mlu *MailingListUpdate) Update(ctx context.Context, conn pg.Conn, scope Scoper) error {
q := `
UPDATE mailing_list_updates
SET
title = @title,
body = @body,
status = @status,
updated_at = @updated_at
WHERE
%s
AND id = @id
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"id": mlu.ID,
"title": mlu.Title,
"body": mlu.Body,
"status": mlu.Status,
"updated_at": mlu.UpdatedAt,
}
maps.Copy(args, scope.SQLArguments())
tag, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot update mailing list update: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrResourceNotFound
}
return nil
}
func (mlu *MailingListUpdate) Delete(ctx context.Context, conn pg.Conn, scope Scoper) error {
q := `
DELETE FROM mailing_list_updates
WHERE
%s
AND id = @id
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"id": mlu.ID,
}
maps.Copy(args, scope.SQLArguments())
tag, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot delete mailing list update: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrResourceNotFound
}
return nil
}
func (mlu *MailingListUpdate) LoadByID(ctx context.Context, conn pg.Conn, scope Scoper, id gid.GID) error {
q := `
SELECT
id,
organization_id,
mailing_list_id,
title,
body,
status,
created_at,
updated_at
FROM mailing_list_updates
WHERE
%s
AND id = @id
LIMIT 1;
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"id": id,
}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query mailing list update: %w", err)
}
result, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[MailingListUpdate])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect mailing list update: %w", err)
}
*mlu = result
return nil
}
func (mlul *MailingListUpdateItems) LoadSentByMailingListID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
mailingListID gid.GID,
cursor *page.Cursor[MailingListUpdateOrderField],
) error {
q := `
SELECT
id,
organization_id,
mailing_list_id,
title,
body,
status,
created_at,
updated_at
FROM mailing_list_updates
WHERE
%s
AND mailing_list_id = @mailing_list_id
AND status = 'SENT'
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
args := pgx.StrictNamedArgs{
"mailing_list_id": mailingListID,
}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, cursor.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query sent mailing list updates: %w", err)
}
results, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[MailingListUpdate])
if err != nil {
return fmt.Errorf("cannot collect sent mailing list updates: %w", err)
}
*mlul = results
return nil
}
func (mlul *MailingListUpdateItems) LoadByMailingListID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
mailingListID gid.GID,
cursor *page.Cursor[MailingListUpdateOrderField],
) error {
q := `
SELECT
id,
organization_id,
mailing_list_id,
title,
body,
status,
created_at,
updated_at
FROM mailing_list_updates
WHERE
%s
AND mailing_list_id = @mailing_list_id
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
args := pgx.StrictNamedArgs{
"mailing_list_id": mailingListID,
}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, cursor.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query mailing list updates: %w", err)
}
results, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[MailingListUpdate])
if err != nil {
return fmt.Errorf("cannot collect mailing list updates: %w", err)
}
*mlul = results
return nil
}
func (mlul *MailingListUpdateItems) CountByMailingListID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
mailingListID gid.GID,
) (int, error) {
q := `
SELECT COUNT(*)
FROM mailing_list_updates
WHERE
%s
AND mailing_list_id = @mailing_list_id
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"mailing_list_id": mailingListID,
}
maps.Copy(args, scope.SQLArguments())
var count int
if err := conn.QueryRow(ctx, q, args).Scan(&count); err != nil {
return 0, fmt.Errorf("cannot count mailing list updates: %w", err)
}
return count, nil
}
func (mlu *MailingListUpdate) LoadNextEnqueuedForUpdateSkipLocked(
ctx context.Context,
conn pg.Conn,
) error {
q := `
SELECT
id,
organization_id,
mailing_list_id,
title,
body,
status,
created_at,
updated_at
FROM mailing_list_updates
WHERE status = 'ENQUEUED'
ORDER BY updated_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED
`
rows, err := conn.Query(ctx, q)
if err != nil {
return fmt.Errorf("cannot query enqueued mailing list updates: %w", err)
}
result, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[MailingListUpdate])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect enqueued mailing list update: %w", err)
}
*mlu = result
return nil
}
func ResetStaleProcessingMailingListUpdates(
ctx context.Context,
conn pg.Conn,
staleAfter time.Duration,
) error {
q := `
UPDATE mailing_list_updates
SET status = 'ENQUEUED', updated_at = NOW()
WHERE status = 'PROCESSING'
AND updated_at < NOW() - @stale_after::interval
`
_, err := conn.Exec(ctx, q, pgx.StrictNamedArgs{"stale_after": staleAfter})
if err != nil {
return fmt.Errorf("cannot reset stale processing mailing list updates: %w", err)
}
return nil
}

View File

@@ -0,0 +1,39 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package coredata
import "fmt"
type MailingListUpdateOrderField string
const (
MailingListUpdateOrderFieldCreatedAt MailingListUpdateOrderField = "CREATED_AT"
MailingListUpdateOrderFieldUpdatedAt MailingListUpdateOrderField = "UPDATED_AT"
)
func (f MailingListUpdateOrderField) String() string {
return string(f)
}
func (f MailingListUpdateOrderField) Column() string {
switch f {
case MailingListUpdateOrderFieldCreatedAt:
return "created_at"
case MailingListUpdateOrderFieldUpdatedAt:
return "updated_at"
}
panic(fmt.Sprintf("unsupported order by: %s", f))
}

View File

@@ -0,0 +1,64 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package coredata
import (
"database/sql/driver"
"fmt"
)
type MailingListUpdateStatus string
const (
MailingListUpdateStatusDraft MailingListUpdateStatus = "DRAFT"
MailingListUpdateStatusEnqueued MailingListUpdateStatus = "ENQUEUED"
MailingListUpdateStatusProcessing MailingListUpdateStatus = "PROCESSING"
MailingListUpdateStatusSent MailingListUpdateStatus = "SENT"
)
func (s MailingListUpdateStatus) String() string {
return string(s)
}
func (s *MailingListUpdateStatus) Scan(value any) error {
var str string
switch v := value.(type) {
case string:
str = v
case []byte:
str = string(v)
default:
return fmt.Errorf("unsupported type for MailingListUpdateStatus: %T", value)
}
switch str {
case "DRAFT":
*s = MailingListUpdateStatusDraft
case "ENQUEUED":
*s = MailingListUpdateStatusEnqueued
case "PROCESSING":
*s = MailingListUpdateStatusProcessing
case "SENT":
*s = MailingListUpdateStatusSent
default:
return fmt.Errorf("invalid MailingListUpdateStatus value: %q", str)
}
return nil
}
func (s MailingListUpdateStatus) Value() (driver.Value, error) {
return s.String(), nil
}

View File

@@ -0,0 +1,18 @@
CREATE TYPE mailing_list_update_status AS ENUM ('DRAFT', 'ENQUEUED', 'PROCESSING', 'SENT');
CREATE TABLE mailing_list_updates (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
organization_id TEXT NOT NULL REFERENCES organizations(id) ON UPDATE CASCADE ON DELETE CASCADE,
mailing_list_id TEXT NOT NULL REFERENCES mailing_lists(id) ON UPDATE CASCADE ON DELETE CASCADE,
title TEXT NOT NULL,
body TEXT NOT NULL,
status mailing_list_update_status NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL
);
ALTER TABLE emails
ADD COLUMN reply_to TEXT,
ADD COLUMN unsubscribe_url TEXT,
ADD COLUMN mailing_list_update_id TEXT REFERENCES mailing_list_updates(id) ON DELETE SET NULL;

View File

@@ -120,6 +120,57 @@ LIMIT 1;
return nil
}
func (tc *TrustCenter) LoadByMailingListID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
mailingListID gid.GID,
) error {
q := `
SELECT
id,
organization_id,
tenant_id,
mailing_list_id,
logo_file_id,
dark_logo_file_id,
active,
slug,
non_disclosure_agreement_file_id,
created_at,
updated_at
FROM
trust_centers
WHERE
%s
AND mailing_list_id = @mailing_list_id
LIMIT 1;
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"mailing_list_id": mailingListID}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query trust center by mailing list id: %w", err)
}
trustCenter, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[TrustCenter])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect trust center: %w", err)
}
*tc = trustCenter
return nil
}
func (tc *TrustCenter) LoadByOrganizationID(
ctx context.Context,
conn pg.Conn,

View File

@@ -321,6 +321,7 @@ func (w *CompletionCertificateWorker) generateCertificate(
subject,
textBody,
htmlBody,
nil,
)
attachments := coredata.EmailAttachments{

View File

@@ -141,6 +141,7 @@ func (s AccountService) ChangeEmail(ctx context.Context, identityID gid.GID, req
subject,
textBody,
htmlBody,
nil,
)
err = confirmationEmail.Insert(ctx, tx)

View File

@@ -352,6 +352,7 @@ func (s AuthService) SendPasswordResetInstructionByEmail(
subject,
textBody,
htmlBody,
nil,
)
err = passwordResetEmail.Insert(ctx, tx)
@@ -420,6 +421,7 @@ func (s AuthService) CreateIdentityWithPassword(
subject,
textBody,
htmlBody,
nil,
)
err = s.pg.WithTx(
@@ -617,6 +619,7 @@ func (s AuthService) SendMagicLink(ctx context.Context, req *SendMagicLinkReques
subject,
textBody,
htmlBody,
nil,
)
if err := magicLinkEmail.Insert(ctx, tx); err != nil {

View File

@@ -415,6 +415,7 @@ func (s *OrganizationService) InviteUser(
subject,
textBody,
htmlBody,
nil,
)
err = email.Insert(ctx, tx)

View File

@@ -208,10 +208,20 @@ func (w *SendingWorker) sendAndCommit(
To(email.RecipientName, email.RecipientEmail).
Text([]byte(email.TextBody))
if email.ReplyTo != nil {
mail = mail.ReplyTo("", email.ReplyTo.String())
}
if email.HtmlBody != nil {
mail = mail.HTML([]byte(*email.HtmlBody))
}
if email.UnsubscribeURL != nil {
mail = mail.
Header("List-Unsubscribe", "<"+*email.UnsubscribeURL+">").
Header("List-Unsubscribe-Post", "List-Unsubscribe=One-Click")
}
for _, att := range attachments {
var file coredata.File
if err := file.LoadByID(ctx, conn, coredata.NewNoScope(), att.FileID); err != nil {

View File

@@ -0,0 +1,161 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package mailman
import (
"context"
"errors"
"fmt"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/packages/emails"
"go.probo.inc/probo/pkg/baseurl"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/mail"
)
func (s *Service) SubscriptionConfirmationEmailConfig(
ctx context.Context,
mailingListID gid.GID,
) (emails.PresenterConfig, string, *mail.Addr, error) {
cfg, orgName, _, replyTo, err := s.mailingListEmailConfig(ctx, mailingListID)
return cfg, orgName, replyTo, err
}
func (s *Service) UnsubscribeEmailConfig(
ctx context.Context,
mailingListID gid.GID,
) (emails.PresenterConfig, string, *mail.Addr, error) {
cfg, orgName, _, replyTo, err := s.mailingListEmailConfig(ctx, mailingListID)
return cfg, orgName, replyTo, err
}
func (s *Service) UpdateEmailConfig(
ctx context.Context,
mailingListID gid.GID,
) (emails.PresenterConfig, string, string, *mail.Addr, error) {
return s.mailingListEmailConfig(ctx, mailingListID)
}
func (s *Service) mailingListEmailConfig(
ctx context.Context,
mailingListID gid.GID,
) (emails.PresenterConfig, string, string, *mail.Addr, error) {
var (
mailingList = &coredata.MailingList{}
compliancePage = &coredata.TrustCenter{}
organization = &coredata.Organization{}
customDomain *coredata.CustomDomain
logoFile = &coredata.File{}
defaultCfg = emails.DefaultPresenterConfig(s.bucket, s.apiBaseURL.String())
)
scope := coredata.NewScopeFromObjectID(mailingListID)
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := mailingList.LoadByID(ctx, conn, scope, mailingListID); err != nil {
return fmt.Errorf("cannot load mailing list: %w", err)
}
if err := compliancePage.LoadByMailingListID(ctx, conn, scope, mailingListID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return err
}
return fmt.Errorf("cannot load compliance page: %w", err)
}
if compliancePage.LogoFileID != nil {
if err := logoFile.LoadByID(ctx, conn, scope, *compliancePage.LogoFileID); err != nil {
return fmt.Errorf("cannot load logo file: %w", err)
}
}
if err := organization.LoadByID(ctx, conn, scope, compliancePage.OrganizationID); err != nil {
return fmt.Errorf("cannot load organization: %w", err)
}
customDomain = &coredata.CustomDomain{}
if err := customDomain.LoadByOrganizationID(ctx, conn, scope, organization.ID); err != nil {
if !errors.Is(err, coredata.ErrResourceNotFound) {
return fmt.Errorf("cannot load custom domain: %w", err)
}
}
return nil
},
)
if err != nil {
return defaultCfg, "", "", nil, err
}
cfg, compliancePageURL, err := s.presenterConfigFromTrustCenter(compliancePage, organization, customDomain, logoFile)
if err != nil {
return defaultCfg, "", "", nil, err
}
compliancePageBase, err := baseurl.Parse(compliancePageURL)
if err != nil {
return defaultCfg, "", "", nil, fmt.Errorf("cannot parse compliance page URL: %w", err)
}
updatesPageURL, err := compliancePageBase.AppendPath("/updates").String()
if err != nil {
return defaultCfg, "", "", nil, fmt.Errorf("cannot build updates page URL: %w", err)
}
return cfg, organization.Name, updatesPageURL, mailingList.ReplyTo, nil
}
func (s *Service) presenterConfigFromTrustCenter(
compliancePage *coredata.TrustCenter,
organization *coredata.Organization,
customDomain *coredata.CustomDomain,
logoFile *coredata.File,
) (emails.PresenterConfig, string, error) {
cfg := emails.DefaultPresenterConfig(s.bucket, s.apiBaseURL.String())
compliancePageBase := s.apiBaseURL.WithPath("/trust/" + compliancePage.ID.String())
if customDomain != nil && customDomain.SSLStatus == coredata.CustomDomainSSLStatusActive {
customBase, err := baseurl.Parse("https://" + customDomain.Domain)
if err != nil {
return cfg, "", fmt.Errorf("cannot parse custom domain URL: %w", err)
}
compliancePageBase = customBase.WithPath("")
}
compliancePageURL, err := compliancePageBase.String()
if err != nil {
return cfg, "", fmt.Errorf("cannot build compliance page URL: %w", err)
}
cfg.BaseURL = compliancePageURL
if compliancePage.LogoFileID != nil && logoFile != nil && logoFile.FileKey != "" {
cfg.SenderCompanyLogo = emails.Asset{
Name: logoFile.FileName,
ObjectKey: logoFile.FileKey,
BucketName: logoFile.BucketName,
MimeType: logoFile.MimeType,
}
cfg.SenderCompanyName = organization.Name
if organization.WebsiteURL != nil {
cfg.SenderCompanyWebsiteURL = *organization.WebsiteURL
}
if organization.HeadquarterAddress != nil {
cfg.SenderCompanyHeadquarterAddress = *organization.HeadquarterAddress
}
}
return cfg, compliancePageURL, nil
}

View File

@@ -17,7 +17,9 @@ package mailman
import "errors"
var (
ErrMailingListNotFound = errors.New("mailing list not found")
ErrSubscriberNotFound = errors.New("mailing list subscriber not found")
ErrSubscriberAlreadyExist = errors.New("mailing list subscriber already exists")
ErrMailingListNotFound = errors.New("mailing list not found")
ErrSubscriberNotFound = errors.New("mailing list subscriber not found")
ErrSubscriberAlreadyExist = errors.New("mailing list subscriber already exists")
ErrMailingListUpdateNotFound = errors.New("mailing list update not found")
ErrMailingListUpdateAlreadySent = errors.New("mailing list update already sent")
)

View File

@@ -0,0 +1,230 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package mailman
import (
"context"
"errors"
"fmt"
"sync"
"time"
"go.gearno.de/kit/log"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
)
type (
MailingListWorker struct {
service *Service
pg *pg.Client
logger *log.Logger
interval time.Duration
staleAfter time.Duration
maxConcurrency int
}
MailingListWorkerOption func(*MailingListWorker)
)
func WithMailingListWorkerInterval(d time.Duration) MailingListWorkerOption {
return func(w *MailingListWorker) { w.interval = d }
}
func WithMailingListWorkerStaleAfter(d time.Duration) MailingListWorkerOption {
return func(w *MailingListWorker) { w.staleAfter = d }
}
func WithMailingListWorkerMaxConcurrency(n int) MailingListWorkerOption {
return func(w *MailingListWorker) {
if n > 0 {
w.maxConcurrency = n
}
}
}
func NewMailingListWorker(
service *Service,
pgClient *pg.Client,
logger *log.Logger,
opts ...MailingListWorkerOption,
) *MailingListWorker {
w := &MailingListWorker{
service: service,
pg: pgClient,
logger: logger,
interval: 10 * time.Second,
staleAfter: 5 * time.Minute,
maxConcurrency: 5,
}
for _, opt := range opts {
opt(w)
}
return w
}
func (w *MailingListWorker) Run(ctx context.Context) error {
var (
wg sync.WaitGroup
sem = make(chan struct{}, w.maxConcurrency)
)
defer wg.Wait()
LOOP:
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(w.interval):
// From there we should not accept cancellations anymore.
nonCancelableCtx := context.WithoutCancel(ctx)
w.recoverStaleRows(nonCancelableCtx)
for {
if err := w.processNext(ctx, sem, &wg); err != nil {
if !errors.Is(err, coredata.ErrResourceNotFound) {
w.logger.ErrorCtx(nonCancelableCtx, "cannot claim mailing list update", log.Error(err))
}
break
}
}
goto LOOP
}
}
func (w *MailingListWorker) processNext(ctx context.Context, sem chan struct{}, wg *sync.WaitGroup) error {
select {
case sem <- struct{}{}:
case <-ctx.Done():
return ctx.Err()
}
var (
mlu coredata.MailingListUpdate
now = time.Now()
nonCancelableCtx = context.WithoutCancel(ctx)
)
if err := w.pg.WithTx(
nonCancelableCtx,
func(tx pg.Conn) error {
if err := mlu.LoadNextEnqueuedForUpdateSkipLocked(nonCancelableCtx, tx); err != nil {
return err
}
scope := coredata.NewScopeFromObjectID(mlu.ID)
mlu.Status = coredata.MailingListUpdateStatusProcessing
mlu.UpdatedAt = now
if err := mlu.Update(nonCancelableCtx, tx, scope); err != nil {
return fmt.Errorf("cannot claim mailing list update: %w", err)
}
return nil
},
); err != nil {
<-sem
return err
}
wg.Add(1)
go func(mlu coredata.MailingListUpdate) {
defer wg.Done()
defer func() { <-sem }()
if err := w.sendAndCommit(nonCancelableCtx, &mlu); err != nil {
w.logger.ErrorCtx(nonCancelableCtx, "cannot send mailing list update",
log.Error(err),
log.String("mailing_list_update_id", mlu.ID.String()),
)
if err := w.resetEnqueued(nonCancelableCtx, &mlu); err != nil {
w.logger.ErrorCtx(nonCancelableCtx, "cannot reset mailing list update to enqueued",
log.Error(err),
log.String("mailing_list_update_id", mlu.ID.String()),
)
}
}
}(mlu)
return nil
}
func (w *MailingListWorker) sendAndCommit(ctx context.Context, mlu *coredata.MailingListUpdate) error {
if err := w.service.CreateUpdateEmails(ctx, mlu.MailingListID, mlu.ID, mlu.Title, mlu.Body); err != nil {
return fmt.Errorf("cannot create update emails: %w", err)
}
return w.pg.WithTx(
ctx,
func(tx pg.Conn) error {
scope := coredata.NewScopeFromObjectID(mlu.ID)
var current coredata.MailingListUpdate
if err := current.LoadByID(ctx, tx, scope, mlu.ID); err != nil {
return fmt.Errorf("cannot reload mailing list update: %w", err)
}
if current.Status != coredata.MailingListUpdateStatusProcessing {
return fmt.Errorf("unexpected status %s, expected PROCESSING", current.Status)
}
mlu.Status = coredata.MailingListUpdateStatusSent
mlu.UpdatedAt = time.Now()
if err := mlu.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot mark mailing list update as sent: %w", err)
}
return nil
},
)
}
func (w *MailingListWorker) resetEnqueued(ctx context.Context, mlu *coredata.MailingListUpdate) error {
return w.pg.WithTx(
ctx,
func(tx pg.Conn) error {
scope := coredata.NewScopeFromObjectID(mlu.ID)
mlu.Status = coredata.MailingListUpdateStatusEnqueued
mlu.UpdatedAt = time.Now()
if err := mlu.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot reset mailing list update: %w", err)
}
return nil
},
)
}
func (w *MailingListWorker) recoverStaleRows(ctx context.Context) {
err := w.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := coredata.ResetStaleProcessingMailingListUpdates(ctx, conn, w.staleAfter); err != nil {
return fmt.Errorf("cannot reset stale processing mailing list updates: %w", err)
}
return nil
},
)
if err != nil {
w.logger.ErrorCtx(ctx, "cannot recover stale processing mailing list updates", log.Error(err))
}
}

View File

@@ -20,19 +20,35 @@ import (
"fmt"
"time"
"go.gearno.de/kit/log"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/packages/emails"
"go.probo.inc/probo/pkg/baseurl"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/crypto/cipher"
"go.probo.inc/probo/pkg/filemanager"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/mail"
"go.probo.inc/probo/pkg/page"
)
const (
pathUnsubscribe = "/mail-actions/unsubscribe"
pathConfirm = "/mail-actions/confirm"
)
type Service struct {
pg *pg.Client
pg *pg.Client
fm *filemanager.Service
tokenSecret string
apiBaseURL *baseurl.BaseURL
bucket string
encryptionKey cipher.EncryptionKey
logger *log.Logger
}
func NewService(pgClient *pg.Client) *Service {
return &Service{pg: pgClient}
func NewService(pgClient *pg.Client, fm *filemanager.Service, tokenSecret string, apiBaseURL *baseurl.BaseURL, bucket string, encryptionKey cipher.EncryptionKey, logger *log.Logger) *Service {
return &Service{pg: pgClient, fm: fm, tokenSecret: tokenSecret, apiBaseURL: apiBaseURL, bucket: bucket, encryptionKey: encryptionKey, logger: logger}
}
func (s *Service) UpdateMailingList(
@@ -40,8 +56,8 @@ func (s *Service) UpdateMailingList(
id gid.GID,
replyTo *mail.Addr,
) (*coredata.MailingList, error) {
var ml coredata.MailingList
scope := coredata.NewScopeFromObjectID(id)
ml := coredata.MailingList{}
err := s.pg.WithConn(
ctx,
@@ -105,8 +121,12 @@ func (s *Service) CreateSubscriber(
fullName string,
) (*coredata.MailingListSubscriber, error) {
scope := coredata.NewScopeFromObjectID(mailingListID)
now := time.Now()
emailRecord, err := s.buildConfirmationMail(ctx, mailingListID, email, fullName)
if err != nil {
return nil, fmt.Errorf("cannot build confirmation mail: %w", err)
}
now := time.Now()
subscriber := &coredata.MailingListSubscriber{
ID: gid.New(scope.GetTenantID(), coredata.MailingListSubscriberEntityType),
MailingListID: mailingListID,
@@ -117,53 +137,150 @@ func (s *Service) CreateSubscriber(
UpdatedAt: now,
}
err := s.pg.WithConn(
if err := s.pg.WithTx(
ctx,
func(conn pg.Conn) error {
ml := coredata.MailingList{}
if err := ml.LoadByID(ctx, conn, scope, mailingListID); err != nil {
func(tx pg.Conn) error {
var ml coredata.MailingList
if err := ml.LoadByID(ctx, tx, scope, mailingListID); err != nil {
return fmt.Errorf("cannot load mailing list: %w", err)
}
subscriber.OrganizationID = ml.OrganizationID
if err := subscriber.Insert(ctx, conn, scope); err != nil {
if err := subscriber.Insert(ctx, tx, scope); err != nil {
if errors.Is(err, coredata.ErrResourceAlreadyExists) {
return ErrSubscriberAlreadyExist
}
return fmt.Errorf("cannot insert mailing list subscriber: %w", err)
}
if err := emailRecord.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot insert subscription confirmation email: %w", err)
}
return nil
},
)
if err != nil {
); err != nil {
return nil, err
}
return subscriber, nil
}
func (s *Service) UnsubscribeByEmail(
ctx context.Context,
mailingListID gid.GID,
email mail.Addr,
) error {
scope := coredata.NewScopeFromObjectID(mailingListID)
return s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
var subscriber coredata.MailingListSubscriber
if err := subscriber.LoadByMailingListIDAndEmail(ctx, tx, scope, mailingListID, email); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrSubscriberNotFound
}
return fmt.Errorf("cannot load mailing list subscriber: %w", err)
}
wasConfirmed := subscriber.Status == coredata.MailingListSubscriberStatusConfirmed
if err := subscriber.Delete(ctx, tx, scope); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrSubscriberNotFound
}
return fmt.Errorf("cannot delete mailing list subscriber: %w", err)
}
if wasConfirmed {
emailRecord, err := s.buildUnsubscriptionMail(ctx, mailingListID, subscriber.Email, subscriber.FullName)
if err != nil {
return fmt.Errorf("cannot build unsubscription email: %w", err)
}
if err := emailRecord.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot insert unsubscription email: %w", err)
}
}
return nil
},
)
}
func (s *Service) ConfirmSubscriberByEmail(
ctx context.Context,
mailingListID gid.GID,
email mail.Addr,
) error {
scope := coredata.NewScopeFromObjectID(mailingListID)
return s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
var subscriber coredata.MailingListSubscriber
if err := subscriber.LoadByMailingListIDAndEmail(ctx, conn, scope, mailingListID, email); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrSubscriberNotFound
}
return fmt.Errorf("cannot load mailing list subscriber: %w", err)
}
subscriber.Status = coredata.MailingListSubscriberStatusConfirmed
subscriber.UpdatedAt = time.Now()
if err := subscriber.Update(ctx, conn, scope); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrSubscriberNotFound
}
return fmt.Errorf("cannot update mailing list subscriber: %w", err)
}
return nil
},
)
}
func (s *Service) DeleteSubscriber(
ctx context.Context,
id gid.GID,
) error {
scope := coredata.NewScopeFromObjectID(id)
err := s.pg.WithConn(
return s.pg.WithTx(
ctx,
func(conn pg.Conn) error {
subscriber := coredata.MailingListSubscriber{ID: id}
if err := subscriber.Delete(ctx, conn, scope); err != nil {
func(tx pg.Conn) error {
var subscriber coredata.MailingListSubscriber
if err := subscriber.LoadByID(ctx, tx, scope, id); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrSubscriberNotFound
}
return fmt.Errorf("cannot load mailing list subscriber: %w", err)
}
wasConfirmed := subscriber.Status == coredata.MailingListSubscriberStatusConfirmed
if err := subscriber.Delete(ctx, tx, scope); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrSubscriberNotFound
}
return fmt.Errorf("cannot delete mailing list subscriber: %w", err)
}
if wasConfirmed {
emailRecord, err := s.buildUnsubscriptionMail(ctx, subscriber.MailingListID, subscriber.Email, subscriber.FullName)
if err != nil {
return fmt.Errorf("cannot build unsubscription email: %w", err)
}
if err := emailRecord.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot insert unsubscription email: %w", err)
}
}
return nil
},
)
if err != nil {
return err
}
return nil
}
func (s *Service) CountSubscribers(
@@ -171,7 +288,7 @@ func (s *Service) CountSubscribers(
mailingListID gid.GID,
) (int, error) {
scope := coredata.NewScopeFromObjectID(mailingListID)
count := 0
var count int
err := s.pg.WithConn(
ctx,
@@ -197,7 +314,7 @@ func (s *Service) ListSubscribers(
cursor *page.Cursor[coredata.MailingListSubscriberOrderField],
) (*page.Page[*coredata.MailingListSubscriber, coredata.MailingListSubscriberOrderField], error) {
scope := coredata.NewScopeFromObjectID(mailingListID)
subscribers := coredata.MailingListSubscribers{}
var subscribers coredata.MailingListSubscribers
err := s.pg.WithConn(
ctx,
@@ -214,3 +331,386 @@ func (s *Service) ListSubscribers(
return page.NewPage(subscribers, cursor), nil
}
func (s *Service) CreateMailingListUpdate(
ctx context.Context,
mailingListID gid.GID,
title string,
body string,
) (*coredata.MailingListUpdate, error) {
scope := coredata.NewScopeFromObjectID(mailingListID)
now := time.Now()
mlu := &coredata.MailingListUpdate{
ID: gid.New(scope.GetTenantID(), coredata.MailingListUpdateEntityType),
MailingListID: mailingListID,
Title: title,
Body: body,
Status: coredata.MailingListUpdateStatusDraft,
CreatedAt: now,
UpdatedAt: now,
}
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
var ml coredata.MailingList
if err := ml.LoadByID(ctx, conn, scope, mailingListID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrMailingListNotFound
}
return fmt.Errorf("cannot load mailing list: %w", err)
}
mlu.OrganizationID = ml.OrganizationID
if err := mlu.Insert(ctx, conn, scope); err != nil {
return fmt.Errorf("cannot insert mailing list update: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return mlu, nil
}
func (s *Service) GetMailingListUpdate(
ctx context.Context,
id gid.GID,
) (*coredata.MailingListUpdate, error) {
scope := coredata.NewScopeFromObjectID(id)
var mlu coredata.MailingListUpdate
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := mlu.LoadByID(ctx, conn, scope, id); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrMailingListUpdateNotFound
}
return fmt.Errorf("cannot load mailing list update: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return &mlu, nil
}
func (s *Service) UpdateMailingListUpdate(
ctx context.Context,
id gid.GID,
title string,
body string,
) (*coredata.MailingListUpdate, error) {
scope := coredata.NewScopeFromObjectID(id)
var mlu coredata.MailingListUpdate
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := mlu.LoadByID(ctx, conn, scope, id); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrMailingListUpdateNotFound
}
return fmt.Errorf("cannot load mailing list update: %w", err)
}
if mlu.Status != coredata.MailingListUpdateStatusDraft {
return ErrMailingListUpdateAlreadySent
}
mlu.Title = title
mlu.Body = body
mlu.UpdatedAt = time.Now()
if err := mlu.Update(ctx, conn, scope); err != nil {
return fmt.Errorf("cannot update mailing list update: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return &mlu, nil
}
func (s *Service) SendMailingListUpdate(
ctx context.Context,
id gid.GID,
) (*coredata.MailingListUpdate, error) {
scope := coredata.NewScopeFromObjectID(id)
var mlu coredata.MailingListUpdate
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := mlu.LoadByID(ctx, conn, scope, id); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrMailingListUpdateNotFound
}
return fmt.Errorf("cannot load mailing list update: %w", err)
}
if mlu.Status != coredata.MailingListUpdateStatusDraft {
return ErrMailingListUpdateAlreadySent
}
mlu.Status = coredata.MailingListUpdateStatusEnqueued
mlu.UpdatedAt = time.Now()
if err := mlu.Update(ctx, conn, scope); err != nil {
return fmt.Errorf("cannot queue mailing list update for sending: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return &mlu, nil
}
func (s *Service) DeleteMailingListUpdate(
ctx context.Context,
id gid.GID,
) error {
scope := coredata.NewScopeFromObjectID(id)
return s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
mlu := coredata.MailingListUpdate{ID: id}
if err := mlu.Delete(ctx, conn, scope); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return ErrMailingListUpdateNotFound
}
return fmt.Errorf("cannot delete mailing list update: %w", err)
}
return nil
},
)
}
func (s *Service) ListMailingListUpdates(
ctx context.Context,
mailingListID gid.GID,
cursor *page.Cursor[coredata.MailingListUpdateOrderField],
) (*page.Page[*coredata.MailingListUpdate, coredata.MailingListUpdateOrderField], error) {
scope := coredata.NewScopeFromObjectID(mailingListID)
var items coredata.MailingListUpdateItems
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := items.LoadByMailingListID(ctx, conn, scope, mailingListID, cursor); err != nil {
return fmt.Errorf("cannot load mailing list updates: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage(items, cursor), nil
}
func (s *Service) ListSentMailingListUpdates(
ctx context.Context,
mailingListID gid.GID,
cursor *page.Cursor[coredata.MailingListUpdateOrderField],
) (*page.Page[*coredata.MailingListUpdate, coredata.MailingListUpdateOrderField], error) {
scope := coredata.NewScopeFromObjectID(mailingListID)
var items coredata.MailingListUpdateItems
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := items.LoadSentByMailingListID(ctx, conn, scope, mailingListID, cursor); err != nil {
return fmt.Errorf("cannot load sent mailing list updates: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage(items, cursor), nil
}
func (s *Service) CountMailingListUpdates(
ctx context.Context,
mailingListID gid.GID,
) (int, error) {
scope := coredata.NewScopeFromObjectID(mailingListID)
var count int
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
var items coredata.MailingListUpdateItems
var err error
count, err = items.CountByMailingListID(ctx, conn, scope, mailingListID)
if err != nil {
return fmt.Errorf("cannot count mailing list updates: %w", err)
}
return nil
},
)
if err != nil {
return 0, err
}
return count, nil
}
func (s *Service) CreateUpdateEmails(
ctx context.Context,
mailingListID gid.GID,
mailingListUpdateID gid.GID,
updateTitle string,
updateBody string,
) error {
scope := coredata.NewScopeFromObjectID(mailingListID)
presenterCfg, orgName, compliancePageURL, replyTo, err := s.UpdateEmailConfig(ctx, mailingListID)
if err != nil {
return fmt.Errorf("cannot get update email config: %w", err)
}
return s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
var subscribers coredata.MailingListSubscribers
if err := subscribers.LoadAllConfirmedByMailingListID(ctx, tx, scope, mailingListID); err != nil {
return fmt.Errorf("cannot load confirmed subscribers: %w", err)
}
if len(subscribers) == 0 {
return nil
}
emailRecords := make(coredata.Emails, 0, len(subscribers))
for _, sub := range subscribers {
unsubscribeURL, err := s.buildUnsubscribeURL(mailingListID, sub.Email)
if err != nil {
return fmt.Errorf("cannot generate unsubscribe URL: %w", err)
}
subject, textBody, htmlBody, err := emails.NewPresenterFromConfig(s.fm, presenterCfg, sub.FullName).
RenderMailingListNews(ctx, orgName, updateTitle, updateBody, compliancePageURL, unsubscribeURL)
if err != nil {
return fmt.Errorf("cannot render mailing list update email: %w", err)
}
emailRecords = append(
emailRecords,
coredata.NewEmail(
sub.FullName,
sub.Email,
subject,
textBody,
htmlBody,
&coredata.EmailOptions{
ReplyTo: replyTo,
UnsubscribeURL: &unsubscribeURL,
MailingListUpdateID: &mailingListUpdateID,
},
),
)
}
if err := emailRecords.BulkInsert(ctx, tx); err != nil {
return fmt.Errorf("cannot bulk insert update emails: %w", err)
}
return nil
},
)
}
func (s *Service) buildConfirmationMail(
ctx context.Context,
mailingListID gid.GID,
email mail.Addr,
fullName string,
) (*coredata.Email, error) {
unsubscribeURL, err := s.buildUnsubscribeURL(mailingListID, email)
if err != nil {
return nil, fmt.Errorf("cannot generate unsubscribe URL: %w", err)
}
confirmURL, err := s.buildConfirmURL(mailingListID, email)
if err != nil {
return nil, fmt.Errorf("cannot generate confirm URL: %w", err)
}
presenterCfg, orgName, replyTo, err := s.SubscriptionConfirmationEmailConfig(ctx, mailingListID)
if err != nil {
return nil, fmt.Errorf("cannot get subscription confirmation email config: %w", err)
}
subject, textBody, htmlBody, err := emails.NewPresenterFromConfig(s.fm, presenterCfg, fullName).
RenderMailingListSubscription(ctx, orgName, confirmURL, unsubscribeURL)
if err != nil {
return nil, fmt.Errorf("cannot render subscription confirmation email: %w", err)
}
return coredata.NewEmail(fullName, email, subject, textBody, htmlBody, &coredata.EmailOptions{ReplyTo: replyTo, UnsubscribeURL: &unsubscribeURL}), nil
}
func (s *Service) buildUnsubscriptionMail(
ctx context.Context,
mailingListID gid.GID,
email mail.Addr,
fullName string,
) (*coredata.Email, error) {
presenterCfg, orgName, replyTo, err := s.UnsubscribeEmailConfig(ctx, mailingListID)
if err != nil {
return nil, fmt.Errorf("cannot get unsubscription email config: %w", err)
}
subject, textBody, htmlBody, err := emails.NewPresenterFromConfig(s.fm, presenterCfg, fullName).
RenderMailingListUnsubscription(ctx, orgName)
if err != nil {
return nil, fmt.Errorf("cannot render unsubscription email: %w", err)
}
return coredata.NewEmail(fullName, email, subject, textBody, htmlBody, &coredata.EmailOptions{ReplyTo: replyTo}), nil
}
func (s *Service) buildUnsubscribeURL(mailingListID gid.GID, email mail.Addr) (string, error) {
if s.tokenSecret == "" {
return "", nil
}
token, err := newUnsubscribeToken(s.tokenSecret, mailingListID, email)
if err != nil {
return "", err
}
return s.apiBaseURL.WithPath(pathUnsubscribe).WithQuery("token", token).String()
}
func (s *Service) buildConfirmURL(mailingListID gid.GID, email mail.Addr) (string, error) {
if s.tokenSecret == "" {
return "", nil
}
token, err := newConfirmToken(s.tokenSecret, mailingListID, email)
if err != nil {
return "", err
}
return s.apiBaseURL.WithPath(pathConfirm).WithQuery("token", token).String()
}

86
pkg/mailman/token.go Normal file
View File

@@ -0,0 +1,86 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package mailman
import (
"fmt"
"time"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/mail"
"go.probo.inc/probo/pkg/statelesstoken"
)
const (
TokenTypeUnsubscribe = "mailing_list_unsubscribe"
TokenTypeConfirm = "mailing_list_confirm_subscription"
unsubscribeTokenExpiry = 365 * 24 * time.Hour
confirmTokenExpiry = 15 * 24 * time.Hour
)
type UnsubscribeTokenData struct {
MailingListID gid.GID `json:"m"`
Email mail.Addr `json:"e"`
}
func newUnsubscribeToken(secret string, mailingListID gid.GID, recipientEmail mail.Addr) (string, error) {
return statelesstoken.NewToken(
secret,
TokenTypeUnsubscribe,
unsubscribeTokenExpiry,
UnsubscribeTokenData{
MailingListID: mailingListID,
Email: recipientEmail,
},
)
}
func ValidateUnsubscribeToken(secret, tokenString string) (*UnsubscribeTokenData, error) {
payload, err := statelesstoken.ValidateToken[UnsubscribeTokenData](secret, TokenTypeUnsubscribe, tokenString)
if err != nil {
return nil, fmt.Errorf("cannot validate unsubscribe token: %w", err)
}
return &payload.Data, nil
}
type ConfirmTokenData struct {
MailingListID gid.GID `json:"m"`
Email mail.Addr `json:"e"`
}
func newConfirmToken(secret string, mailingListID gid.GID, recipientEmail mail.Addr) (string, error) {
return statelesstoken.NewToken(
secret,
TokenTypeConfirm,
confirmTokenExpiry,
ConfirmTokenData{
MailingListID: mailingListID,
Email: recipientEmail,
},
)
}
// ValidateConfirmToken validates a subscription confirmation token and returns
// the embedded payload. Exported so the HTTP handler can use it.
func ValidateConfirmToken(secret, tokenString string) (*ConfirmTokenData, error) {
payload, err := statelesstoken.ValidateToken[ConfirmTokenData](secret, TokenTypeConfirm, tokenString)
if err != nil {
return nil, fmt.Errorf("cannot validate confirm token: %w", err)
}
return &payload.Data, nil
}

View File

@@ -40,6 +40,13 @@ const (
ActionTrustCenterAccessUpdate = "core:trust-center-access:update"
ActionTrustCenterAccessDelete = "core:trust-center-access:delete"
// MailingListUpdate actions
ActionMailingListUpdateList = "core:mailing-list-update:list"
ActionMailingListUpdateCreate = "core:mailing-list-update:create"
ActionMailingListUpdateUpdate = "core:mailing-list-update:update"
ActionMailingListUpdateSend = "core:mailing-list-update:send"
ActionMailingListUpdateDelete = "core:mailing-list-update:delete"
// MailingList actions
ActionMailingListUpdate = "core:mailing-list:update"

View File

@@ -664,6 +664,7 @@ func (s *DocumentService) SendSigningNotifications(
subject,
textBody,
htmlBody,
nil,
)
if err := email.Insert(ctx, tx); err != nil {
@@ -2010,6 +2011,7 @@ func (s *DocumentService) SendExportEmail(
subject,
textBody,
htmlBody,
nil,
)
if err := email.Insert(ctx, tx); err != nil {

View File

@@ -635,6 +635,7 @@ func (s FrameworkService) SendExportEmail(
subject,
textBody,
htmlBody,
nil,
)
if err := email.Insert(ctx, tx); err != nil {

View File

@@ -374,6 +374,7 @@ func (s TrustCenterAccessService) sendAccessEmail(ctx context.Context, tx pg.Con
subject,
textBody,
htmlBody,
nil,
)
if err := accessEmail.Insert(ctx, tx); err != nil {

View File

@@ -436,6 +436,8 @@ func (impl *Implm) Run(
l.Named("esign"),
)
mailmanService := mailman.NewService(pgClient, fileManagerService, impl.cfg.Auth.Cookie.Secret, baseURL, impl.cfg.AWS.Bucket, encryptionKey, l)
proboService, err := probo.NewService(
ctx,
encryptionKey,
@@ -472,8 +474,6 @@ func (impl *Implm) Run(
slackService,
)
mailmanService := mailman.NewService(pgClient)
serverHandler, err := server.NewServer(
server.Config{
AllowedOrigins: impl.cfg.Api.Cors.AllowedOrigins,
@@ -593,6 +593,16 @@ func (impl *Implm) Run(
},
)
mailingListWorker := mailman.NewMailingListWorker(mailmanService, pgClient, l.Named("mailing-list-worker"))
mailingListWorkerCtx, stopMailingListWorker := context.WithCancel(context.Background())
wg.Go(
func() {
if err := mailingListWorker.Run(mailingListWorkerCtx); err != nil {
cancel(fmt.Errorf("mailing list worker crashed: %w", err))
}
},
)
trustCenterServerCtx, stopTrustCenterServer := context.WithCancel(context.Background())
defer stopTrustCenterServer()
wg.Go(
@@ -619,6 +629,7 @@ func (impl *Implm) Run(
stopTrustCenterServer()
stopWebhookSender()
stopESignService()
stopMailingListWorker()
stopExportJobExporter()
stopIAMService()
stopMailer()

View File

@@ -1695,6 +1695,13 @@ type MailingList implements Node {
last: Int
before: CursorKey
): MailingListSubscriberConnection! @goField(forceResolver: true)
updates(
first: Int
after: CursorKey
last: Int
before: CursorKey
): MailingListUpdateConnection! @goField(forceResolver: true)
}
enum MailingListSubscriberStatus
@@ -1734,6 +1741,51 @@ type MailingListSubscriberEdge {
node: MailingListSubscriber!
}
enum MailingListUpdateStatus
@goModel(
model: "go.probo.inc/probo/pkg/coredata.MailingListUpdateStatus"
) {
DRAFT
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.MailingListUpdateStatusDraft"
)
ENQUEUED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.MailingListUpdateStatusEnqueued"
)
PROCESSING
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.MailingListUpdateStatusProcessing"
)
SENT
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.MailingListUpdateStatusSent"
)
}
type MailingListUpdate implements Node {
id: ID!
title: String!
body: String!
status: MailingListUpdateStatus!
createdAt: Datetime!
updatedAt: Datetime!
}
type MailingListUpdateConnection
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.MailingListUpdateConnection"
) {
edges: [MailingListUpdateEdge!]!
pageInfo: PageInfo!
totalCount: Int! @goField(forceResolver: true)
}
type MailingListUpdateEdge {
cursor: CursorKey!
node: MailingListUpdate!
}
type Organization implements Node {
id: ID!
name: String!
@@ -3407,6 +3459,19 @@ type Mutation {
deleteTrustCenterAccess(
input: DeleteTrustCenterAccessInput!
): DeleteTrustCenterAccessPayload!
# Compliance News mutations
createMailingListUpdate(
input: CreateMailingListUpdateInput!
): CreateMailingListUpdatePayload!
updateMailingListUpdate(
input: UpdateMailingListUpdateInput!
): UpdateMailingListUpdatePayload!
sendMailingListUpdate(
input: SendMailingListUpdateInput!
): SendMailingListUpdatePayload!
deleteMailingListUpdate(
input: DeleteMailingListUpdateInput!
): DeleteMailingListUpdatePayload!
# Mailing List mutations
updateMailingList(
input: UpdateMailingListInput!
@@ -3829,6 +3894,26 @@ type UpdateMailingListPayload {
mailingList: MailingList!
}
input CreateMailingListUpdateInput {
mailingListId: ID!
title: String!
body: String!
}
input UpdateMailingListUpdateInput {
id: ID!
title: String!
body: String!
}
input SendMailingListUpdateInput {
id: ID!
}
input DeleteMailingListUpdateInput {
id: ID!
}
input CreateMailingListSubscriberInput {
mailingListId: ID!
fullName: String!
@@ -4690,6 +4775,22 @@ type DeleteTrustCenterAccessPayload {
deletedTrustCenterAccessId: ID!
}
type CreateMailingListUpdatePayload {
mailingListUpdate: MailingListUpdate!
}
type UpdateMailingListUpdatePayload {
mailingListUpdate: MailingListUpdate!
}
type SendMailingListUpdatePayload {
mailingListUpdate: MailingListUpdate!
}
type DeleteMailingListUpdatePayload {
deletedMailingListUpdateId: ID!
}
type CreateMailingListSubscriberPayload {
mailingListSubscriberEdge: MailingListSubscriberEdge!
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,66 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package types
import (
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type MailingListUpdateConnection struct {
TotalCount int
Edges []*MailingListUpdateEdge
PageInfo *PageInfo
Resolver any
ParentID gid.GID
}
func NewMailingListUpdate(mlu *coredata.MailingListUpdate) *MailingListUpdate {
return &MailingListUpdate{
ID: mlu.ID,
Title: mlu.Title,
Body: mlu.Body,
Status: mlu.Status,
CreatedAt: mlu.CreatedAt,
UpdatedAt: mlu.UpdatedAt,
}
}
func NewMailingListUpdateEdge(mlu *coredata.MailingListUpdate, orderBy coredata.MailingListUpdateOrderField) *MailingListUpdateEdge {
return &MailingListUpdateEdge{
Cursor: mlu.CursorKey(orderBy),
Node: NewMailingListUpdate(mlu),
}
}
func NewMailingListUpdateConnection(
p *page.Page[*coredata.MailingListUpdate, coredata.MailingListUpdateOrderField],
resolver any,
mailingListID gid.GID,
) *MailingListUpdateConnection {
edges := make([]*MailingListUpdateEdge, len(p.Data))
for i := range edges {
edges[i] = NewMailingListUpdateEdge(p.Data[i], p.Cursor.OrderBy.Field)
}
return &MailingListUpdateConnection{
Edges: edges,
PageInfo: NewPageInfo(p),
Resolver: resolver,
ParentID: mailingListID,
}
}

View File

@@ -446,6 +446,16 @@ type CreateMailingListSubscriberPayload struct {
MailingListSubscriberEdge *MailingListSubscriberEdge `json:"mailingListSubscriberEdge"`
}
type CreateMailingListUpdateInput struct {
MailingListID gid.GID `json:"mailingListId"`
Title string `json:"title"`
Body string `json:"body"`
}
type CreateMailingListUpdatePayload struct {
MailingListUpdate *MailingListUpdate `json:"mailingListUpdate"`
}
type CreateMeasureInput struct {
OrganizationID gid.GID `json:"organizationId"`
Name string `json:"name"`
@@ -977,6 +987,14 @@ type DeleteMailingListSubscriberPayload struct {
DeletedMailingListSubscriberID gid.GID `json:"deletedMailingListSubscriberId"`
}
type DeleteMailingListUpdateInput struct {
ID gid.GID `json:"id"`
}
type DeleteMailingListUpdatePayload struct {
DeletedMailingListUpdateID gid.GID `json:"deletedMailingListUpdateId"`
}
type DeleteMeasureInput struct {
MeasureID gid.GID `json:"measureId"`
}
@@ -1453,6 +1471,7 @@ type MailingList struct {
ID gid.GID `json:"id"`
ReplyTo *mail.Addr `json:"replyTo,omitempty"`
Subscribers *MailingListSubscriberConnection `json:"subscribers"`
Updates *MailingListUpdateConnection `json:"updates"`
}
func (MailingList) IsNode() {}
@@ -1475,6 +1494,23 @@ type MailingListSubscriberEdge struct {
Node *MailingListSubscriber `json:"node"`
}
type MailingListUpdate struct {
ID gid.GID `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
Status coredata.MailingListUpdateStatus `json:"status"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (MailingListUpdate) IsNode() {}
func (this MailingListUpdate) GetID() gid.GID { return this.ID }
type MailingListUpdateEdge struct {
Cursor page.CursorKey `json:"cursor"`
Node *MailingListUpdate `json:"node"`
}
type Measure struct {
ID gid.GID `json:"id"`
Category string `json:"category"`
@@ -1815,6 +1851,14 @@ type RiskFilter struct {
SnapshotID *gid.GID `json:"snapshotId,omitempty"`
}
type SendMailingListUpdateInput struct {
ID gid.GID `json:"id"`
}
type SendMailingListUpdatePayload struct {
MailingListUpdate *MailingListUpdate `json:"mailingListUpdate"`
}
type SendSigningNotificationsInput struct {
OrganizationID gid.GID `json:"organizationId"`
}
@@ -2181,6 +2225,16 @@ type UpdateMailingListPayload struct {
MailingList *MailingList `json:"mailingList"`
}
type UpdateMailingListUpdateInput struct {
ID gid.GID `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
}
type UpdateMailingListUpdatePayload struct {
MailingListUpdate *MailingListUpdate `json:"mailingListUpdate"`
}
type UpdateMeasureInput struct {
ID gid.GID `json:"id"`
Name *string `json:"name,omitempty"`

View File

@@ -18,6 +18,7 @@ import (
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/mailman"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/probo"
"go.probo.inc/probo/pkg/server/api/authn"
@@ -1546,6 +1547,28 @@ func (r *mailingListResolver) Subscribers(ctx context.Context, obj *types.Mailin
return types.NewMailingListSubscriberConnection(result, r, obj.ID), nil
}
// Updates is the resolver for the updates field on MailingList.
func (r *mailingListResolver) Updates(ctx context.Context, obj *types.MailingList, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.MailingListUpdateConnection, error) {
if err := r.authorize(ctx, obj.ID, probo.ActionMailingListUpdateList); err != nil {
return nil, err
}
pageOrderBy := page.OrderBy[coredata.MailingListUpdateOrderField]{
Field: coredata.MailingListUpdateOrderFieldUpdatedAt,
Direction: page.OrderDirectionDesc,
}
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
result, err := r.mailman.ListMailingListUpdates(ctx, obj.ID, cursor)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot list mailing list updates", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewMailingListUpdateConnection(result, r, obj.ID), nil
}
// TotalCount is the resolver for the totalCount field.
func (r *mailingListSubscriberConnectionResolver) TotalCount(ctx context.Context, obj *types.MailingListSubscriberConnection) (int, error) {
if err := r.authorize(ctx, obj.ParentID, probo.ActionMailingListSubscriberList); err != nil {
@@ -1565,6 +1588,21 @@ func (r *mailingListSubscriberConnectionResolver) TotalCount(ctx context.Context
panic(fmt.Errorf("not implemented: TotalCount for parent type %T", obj.Resolver))
}
// TotalCount is the resolver for the totalCount field on MailingListUpdateConnection.
func (r *mailingListUpdateConnectionResolver) TotalCount(ctx context.Context, obj *types.MailingListUpdateConnection) (int, error) {
if err := r.authorize(ctx, obj.ParentID, probo.ActionMailingListUpdateList); err != nil {
return 0, err
}
count, err := r.mailman.CountMailingListUpdates(ctx, obj.ParentID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot count mailing list updates", log.Error(err))
return 0, gqlutils.Internal(ctx)
}
return count, nil
}
// Evidences is the resolver for the evidences field.
func (r *measureResolver) Evidences(ctx context.Context, obj *types.Measure, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.EvidenceOrderBy) (*types.EvidenceConnection, error) {
if err := r.authorize(ctx, obj.ID, probo.ActionEvidenceList); err != nil {
@@ -2028,6 +2066,88 @@ func (r *mutationResolver) DeleteTrustCenterAccess(ctx context.Context, input ty
}, nil
}
// CreateMailingListUpdate is the resolver for the createMailingListUpdate field.
func (r *mutationResolver) CreateMailingListUpdate(ctx context.Context, input types.CreateMailingListUpdateInput) (*types.CreateMailingListUpdatePayload, error) {
if err := r.authorize(ctx, input.MailingListID, probo.ActionMailingListUpdateCreate); err != nil {
return nil, err
}
mlu, err := r.mailman.CreateMailingListUpdate(ctx, input.MailingListID, input.Title, input.Body)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot create mailing list update", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.CreateMailingListUpdatePayload{
MailingListUpdate: types.NewMailingListUpdate(mlu),
}, nil
}
// UpdateMailingListUpdate is the resolver for the updateMailingListUpdate field.
func (r *mutationResolver) UpdateMailingListUpdate(ctx context.Context, input types.UpdateMailingListUpdateInput) (*types.UpdateMailingListUpdatePayload, error) {
if err := r.authorize(ctx, input.ID, probo.ActionMailingListUpdateUpdate); err != nil {
return nil, err
}
mlu, err := r.mailman.UpdateMailingListUpdate(ctx, input.ID, input.Title, input.Body)
if err != nil {
if errors.Is(err, mailman.ErrMailingListUpdateAlreadySent) {
return nil, gqlutils.Conflictf(ctx, "mailing list update can only be edited when in draft")
}
if errors.Is(err, mailman.ErrMailingListUpdateNotFound) {
return nil, gqlutils.NotFoundf(ctx, "mailing list update not found")
}
r.logger.ErrorCtx(ctx, "cannot update mailing list update", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.UpdateMailingListUpdatePayload{
MailingListUpdate: types.NewMailingListUpdate(mlu),
}, nil
}
// SendMailingListUpdate is the resolver for the sendMailingListUpdate field.
func (r *mutationResolver) SendMailingListUpdate(ctx context.Context, input types.SendMailingListUpdateInput) (*types.SendMailingListUpdatePayload, error) {
if err := r.authorize(ctx, input.ID, probo.ActionMailingListUpdateUpdate); err != nil {
return nil, err
}
mlu, err := r.mailman.SendMailingListUpdate(ctx, input.ID)
if err != nil {
if errors.Is(err, mailman.ErrMailingListUpdateAlreadySent) {
return nil, gqlutils.Conflictf(ctx, "mailing list update has already been queued for sending")
}
if errors.Is(err, mailman.ErrMailingListUpdateNotFound) {
return nil, gqlutils.NotFoundf(ctx, "mailing list update not found")
}
r.logger.ErrorCtx(ctx, "cannot queue mailing list update for sending", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.SendMailingListUpdatePayload{
MailingListUpdate: types.NewMailingListUpdate(mlu),
}, nil
}
// DeleteMailingListUpdate is the resolver for the deleteMailingListUpdate field.
func (r *mutationResolver) DeleteMailingListUpdate(ctx context.Context, input types.DeleteMailingListUpdateInput) (*types.DeleteMailingListUpdatePayload, error) {
if err := r.authorize(ctx, input.ID, probo.ActionMailingListUpdateDelete); err != nil {
return nil, err
}
if err := r.mailman.DeleteMailingListUpdate(ctx, input.ID); err != nil {
if errors.Is(err, mailman.ErrMailingListUpdateNotFound) {
return nil, gqlutils.NotFoundf(ctx, "mailing list update not found")
}
r.logger.ErrorCtx(ctx, "cannot delete mailing list update", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.DeleteMailingListUpdatePayload{
DeletedMailingListUpdateID: input.ID,
}, nil
}
// UpdateMailingList is the resolver for the updateMailingList field.
func (r *mutationResolver) UpdateMailingList(ctx context.Context, input types.UpdateMailingListInput) (*types.UpdateMailingListPayload, error) {
if err := r.authorize(ctx, input.ID, probo.ActionMailingListUpdate); err != nil {
@@ -2051,8 +2171,16 @@ func (r *mutationResolver) CreateMailingListSubscriber(ctx context.Context, inpu
return nil, err
}
subscriber, err := r.mailman.CreateSubscriber(ctx, input.MailingListID, input.Email, input.FullName)
subscriber, err := r.mailman.CreateSubscriber(
ctx,
input.MailingListID,
input.Email,
input.FullName,
)
if err != nil {
if errors.Is(err, mailman.ErrSubscriberAlreadyExist) {
return nil, gqlutils.Conflictf(ctx, "subscriber already exists in this mailing list")
}
r.logger.ErrorCtx(ctx, "cannot create mailing list subscriber", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
@@ -2069,6 +2197,9 @@ func (r *mutationResolver) DeleteMailingListSubscriber(ctx context.Context, inpu
}
if err := r.mailman.DeleteSubscriber(ctx, input.ID); err != nil {
if errors.Is(err, mailman.ErrSubscriberNotFound) {
return nil, gqlutils.NotFoundf(ctx, "mailing list subscriber not found")
}
r.logger.ErrorCtx(ctx, "cannot delete mailing list subscriber", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
@@ -9196,6 +9327,11 @@ func (r *Resolver) MailingListSubscriberConnection() schema.MailingListSubscribe
return &mailingListSubscriberConnectionResolver{r}
}
// MailingListUpdateConnection returns schema.MailingListUpdateConnectionResolver implementation.
func (r *Resolver) MailingListUpdateConnection() schema.MailingListUpdateConnectionResolver {
return &mailingListUpdateConnectionResolver{r}
}
// Measure returns schema.MeasureResolver implementation.
func (r *Resolver) Measure() schema.MeasureResolver { return &measureResolver{r} }
@@ -9432,6 +9568,7 @@ type frameworkResolver struct{ *Resolver }
type frameworkConnectionResolver struct{ *Resolver }
type mailingListResolver struct{ *Resolver }
type mailingListSubscriberConnectionResolver struct{ *Resolver }
type mailingListUpdateConnectionResolver struct{ *Resolver }
type measureResolver struct{ *Resolver }
type measureConnectionResolver struct{ *Resolver }
type meetingResolver struct{ *Resolver }

View File

@@ -90,7 +90,6 @@ func NewMux(
r.Use(authn.NewSessionMiddleware(iamSvc, cookieConfig))
graphqlHandler := NewGraphQLHandler(iamSvc, trustSvc, esignSvc, mailmanSvc, logger, baseURL, cookieConfig, tokenSecret)
r.Handle("/graphql", graphqlHandler)
return r
@@ -99,4 +98,3 @@ func NewMux(
func (r *Resolver) TrustService(ctx context.Context, tenantID gid.TenantID) *trust.TenantService {
return r.trust.WithTenant(tenantID)
}

View File

@@ -131,6 +131,23 @@ type ComplianceFrameworkEdge
node: ComplianceFramework!
}
type MailingListUpdate implements Node @nda {
id: ID!
title: String!
body: String!
updatedAt: Datetime!
}
type MailingListUpdateConnection @nda {
edges: [MailingListUpdateEdge!]!
pageInfo: PageInfo!
}
type MailingListUpdateEdge @nda {
cursor: CursorKey!
node: MailingListUpdate!
}
enum CountryCode
@goModel(model: "go.probo.inc/probo/pkg/coredata.CountryCode") {
AD @goEnum(value: "go.probo.inc/probo/pkg/coredata.CountryCodeAD")
@@ -581,6 +598,13 @@ type TrustCenter implements Node {
last: Int
before: CursorKey
): ComplianceExternalURLConnection! @goField(forceResolver: true)
updates(
first: Int
after: CursorKey
last: Int
before: CursorKey
): MailingListUpdateConnection! @goField(forceResolver: true)
}
type ComplianceExternalURL implements Node {
@@ -926,6 +950,7 @@ type MailingListSubscriber implements Node
model: "go.probo.inc/probo/pkg/server/api/trust/v1/types.MailingListSubscriber"
) {
id: ID!
fullName: String!
email: EmailAddr!
status: MailingListSubscriberStatus!
createdAt: Datetime!

View File

@@ -175,11 +175,29 @@ type ComplexityRoot struct {
MailingListSubscriber struct {
CreatedAt func(childComplexity int) int
Email func(childComplexity int) int
FullName func(childComplexity int) int
ID func(childComplexity int) int
Status func(childComplexity int) int
UpdatedAt func(childComplexity int) int
}
MailingListUpdate struct {
Body func(childComplexity int) int
ID func(childComplexity int) int
Title func(childComplexity int) int
UpdatedAt func(childComplexity int) int
}
MailingListUpdateConnection struct {
Edges func(childComplexity int) int
PageInfo func(childComplexity int) int
}
MailingListUpdateEdge struct {
Cursor func(childComplexity int) int
Node func(childComplexity int) int
}
Mutation struct {
AcceptElectronicSignature func(childComplexity int, input types.AcceptElectronicSignatureInput) int
ExportDocumentPDF func(childComplexity int, input types.ExportDocumentPDFInput) int
@@ -275,6 +293,7 @@ type ComplexityRoot struct {
References func(childComplexity int, first *int, after *page.CursorKey, last *int, before *page.CursorKey) int
Slug func(childComplexity int) int
TrustCenterFiles func(childComplexity int, first *int, after *page.CursorKey, last *int, before *page.CursorKey) int
Updates func(childComplexity int, first *int, after *page.CursorKey, last *int, before *page.CursorKey) int
Vendors func(childComplexity int, first *int, after *page.CursorKey, last *int, before *page.CursorKey) int
ViewerSubscription func(childComplexity int) int
}
@@ -417,6 +436,7 @@ type TrustCenterResolver interface {
TrustCenterFiles(ctx context.Context, obj *types.TrustCenter, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.TrustCenterFileConnection, error)
ComplianceFrameworks(ctx context.Context, obj *types.TrustCenter, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.ComplianceFrameworkConnection, error)
ExternalUrls(ctx context.Context, obj *types.TrustCenter, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.ComplianceExternalURLConnection, error)
Updates(ctx context.Context, obj *types.TrustCenter, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.MailingListUpdateConnection, error)
}
type TrustCenterFileResolver interface {
IsUserAuthorized(ctx context.Context, obj *types.TrustCenterFile) (bool, error)
@@ -805,6 +825,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
}
return e.ComplexityRoot.MailingListSubscriber.Email(childComplexity), true
case "MailingListSubscriber.fullName":
if e.ComplexityRoot.MailingListSubscriber.FullName == nil {
break
}
return e.ComplexityRoot.MailingListSubscriber.FullName(childComplexity), true
case "MailingListSubscriber.id":
if e.ComplexityRoot.MailingListSubscriber.ID == nil {
break
@@ -824,6 +850,57 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
return e.ComplexityRoot.MailingListSubscriber.UpdatedAt(childComplexity), true
case "MailingListUpdate.body":
if e.ComplexityRoot.MailingListUpdate.Body == nil {
break
}
return e.ComplexityRoot.MailingListUpdate.Body(childComplexity), true
case "MailingListUpdate.id":
if e.ComplexityRoot.MailingListUpdate.ID == nil {
break
}
return e.ComplexityRoot.MailingListUpdate.ID(childComplexity), true
case "MailingListUpdate.title":
if e.ComplexityRoot.MailingListUpdate.Title == nil {
break
}
return e.ComplexityRoot.MailingListUpdate.Title(childComplexity), true
case "MailingListUpdate.updatedAt":
if e.ComplexityRoot.MailingListUpdate.UpdatedAt == nil {
break
}
return e.ComplexityRoot.MailingListUpdate.UpdatedAt(childComplexity), true
case "MailingListUpdateConnection.edges":
if e.ComplexityRoot.MailingListUpdateConnection.Edges == nil {
break
}
return e.ComplexityRoot.MailingListUpdateConnection.Edges(childComplexity), true
case "MailingListUpdateConnection.pageInfo":
if e.ComplexityRoot.MailingListUpdateConnection.PageInfo == nil {
break
}
return e.ComplexityRoot.MailingListUpdateConnection.PageInfo(childComplexity), true
case "MailingListUpdateEdge.cursor":
if e.ComplexityRoot.MailingListUpdateEdge.Cursor == nil {
break
}
return e.ComplexityRoot.MailingListUpdateEdge.Cursor(childComplexity), true
case "MailingListUpdateEdge.node":
if e.ComplexityRoot.MailingListUpdateEdge.Node == nil {
break
}
return e.ComplexityRoot.MailingListUpdateEdge.Node(childComplexity), true
case "Mutation.acceptElectronicSignature":
if e.ComplexityRoot.Mutation.AcceptElectronicSignature == nil {
break
@@ -1258,6 +1335,17 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
}
return e.ComplexityRoot.TrustCenter.TrustCenterFiles(childComplexity, args["first"].(*int), args["after"].(*page.CursorKey), args["last"].(*int), args["before"].(*page.CursorKey)), true
case "TrustCenter.updates":
if e.ComplexityRoot.TrustCenter.Updates == nil {
break
}
args, err := ec.field_TrustCenter_updates_args(ctx, rawArgs)
if err != nil {
return 0, false
}
return e.ComplexityRoot.TrustCenter.Updates(childComplexity, args["first"].(*int), args["after"].(*page.CursorKey), args["last"].(*int), args["before"].(*page.CursorKey)), true
case "TrustCenter.vendors":
if e.ComplexityRoot.TrustCenter.Vendors == nil {
break
@@ -1744,6 +1832,23 @@ type ComplianceFrameworkEdge
node: ComplianceFramework!
}
type MailingListUpdate implements Node @nda {
id: ID!
title: String!
body: String!
updatedAt: Datetime!
}
type MailingListUpdateConnection @nda {
edges: [MailingListUpdateEdge!]!
pageInfo: PageInfo!
}
type MailingListUpdateEdge @nda {
cursor: CursorKey!
node: MailingListUpdate!
}
enum CountryCode
@goModel(model: "go.probo.inc/probo/pkg/coredata.CountryCode") {
AD @goEnum(value: "go.probo.inc/probo/pkg/coredata.CountryCodeAD")
@@ -2194,6 +2299,13 @@ type TrustCenter implements Node {
last: Int
before: CursorKey
): ComplianceExternalURLConnection! @goField(forceResolver: true)
updates(
first: Int
after: CursorKey
last: Int
before: CursorKey
): MailingListUpdateConnection! @goField(forceResolver: true)
}
type ComplianceExternalURL implements Node {
@@ -2539,6 +2651,7 @@ type MailingListSubscriber implements Node
model: "go.probo.inc/probo/pkg/server/api/trust/v1/types.MailingListSubscriber"
) {
id: ID!
fullName: String!
email: EmailAddr!
status: MailingListSubscriberStatus!
createdAt: Datetime!
@@ -2901,6 +3014,32 @@ func (ec *executionContext) field_TrustCenter_trustCenterFiles_args(ctx context.
return args, nil
}
func (ec *executionContext) field_TrustCenter_updates_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
var err error
args := map[string]any{}
arg0, err := graphql.ProcessArgField(ctx, rawArgs, "first", ec.unmarshalOInt2ᚖint)
if err != nil {
return nil, err
}
args["first"] = arg0
arg1, err := graphql.ProcessArgField(ctx, rawArgs, "after", ec.unmarshalOCursorKey2ᚖgoᚗproboᚗincᚋproboᚋpkgᚋpageᚐCursorKey)
if err != nil {
return nil, err
}
args["after"] = arg1
arg2, err := graphql.ProcessArgField(ctx, rawArgs, "last", ec.unmarshalOInt2ᚖint)
if err != nil {
return nil, err
}
args["last"] = arg2
arg3, err := graphql.ProcessArgField(ctx, rawArgs, "before", ec.unmarshalOCursorKey2ᚖgoᚗproboᚗincᚋproboᚋpkgᚋpageᚐCursorKey)
if err != nil {
return nil, err
}
args["before"] = arg3
return args, nil
}
func (ec *executionContext) field_TrustCenter_vendors_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
var err error
args := map[string]any{}
@@ -4850,6 +4989,35 @@ func (ec *executionContext) fieldContext_MailingListSubscriber_id(_ context.Cont
return fc, nil
}
func (ec *executionContext) _MailingListSubscriber_fullName(ctx context.Context, field graphql.CollectedField, obj *types.MailingListSubscriber) (ret graphql.Marshaler) {
return graphql.ResolveField(
ctx,
ec.OperationContext,
field,
ec.fieldContext_MailingListSubscriber_fullName,
func(ctx context.Context) (any, error) {
return obj.FullName, nil
},
nil,
ec.marshalNString2string,
true,
true,
)
}
func (ec *executionContext) fieldContext_MailingListSubscriber_fullName(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "MailingListSubscriber",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type String does not have child fields")
},
}
return fc, nil
}
func (ec *executionContext) _MailingListSubscriber_email(ctx context.Context, field graphql.CollectedField, obj *types.MailingListSubscriber) (ret graphql.Marshaler) {
return graphql.ResolveField(
ctx,
@@ -4966,6 +5134,290 @@ func (ec *executionContext) fieldContext_MailingListSubscriber_updatedAt(_ conte
return fc, nil
}
func (ec *executionContext) _MailingListUpdate_id(ctx context.Context, field graphql.CollectedField, obj *types.MailingListUpdate) (ret graphql.Marshaler) {
return graphql.ResolveField(
ctx,
ec.OperationContext,
field,
ec.fieldContext_MailingListUpdate_id,
func(ctx context.Context) (any, error) {
return obj.ID, nil
},
nil,
ec.marshalNID2goᚗproboᚗincᚋproboᚋpkgᚋgidᚐGID,
true,
true,
)
}
func (ec *executionContext) fieldContext_MailingListUpdate_id(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "MailingListUpdate",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type ID does not have child fields")
},
}
return fc, nil
}
func (ec *executionContext) _MailingListUpdate_title(ctx context.Context, field graphql.CollectedField, obj *types.MailingListUpdate) (ret graphql.Marshaler) {
return graphql.ResolveField(
ctx,
ec.OperationContext,
field,
ec.fieldContext_MailingListUpdate_title,
func(ctx context.Context) (any, error) {
return obj.Title, nil
},
nil,
ec.marshalNString2string,
true,
true,
)
}
func (ec *executionContext) fieldContext_MailingListUpdate_title(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "MailingListUpdate",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type String does not have child fields")
},
}
return fc, nil
}
func (ec *executionContext) _MailingListUpdate_body(ctx context.Context, field graphql.CollectedField, obj *types.MailingListUpdate) (ret graphql.Marshaler) {
return graphql.ResolveField(
ctx,
ec.OperationContext,
field,
ec.fieldContext_MailingListUpdate_body,
func(ctx context.Context) (any, error) {
return obj.Body, nil
},
nil,
ec.marshalNString2string,
true,
true,
)
}
func (ec *executionContext) fieldContext_MailingListUpdate_body(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "MailingListUpdate",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type String does not have child fields")
},
}
return fc, nil
}
func (ec *executionContext) _MailingListUpdate_updatedAt(ctx context.Context, field graphql.CollectedField, obj *types.MailingListUpdate) (ret graphql.Marshaler) {
return graphql.ResolveField(
ctx,
ec.OperationContext,
field,
ec.fieldContext_MailingListUpdate_updatedAt,
func(ctx context.Context) (any, error) {
return obj.UpdatedAt, nil
},
nil,
ec.marshalNDatetime2timeᚐTime,
true,
true,
)
}
func (ec *executionContext) fieldContext_MailingListUpdate_updatedAt(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "MailingListUpdate",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type Datetime does not have child fields")
},
}
return fc, nil
}
func (ec *executionContext) _MailingListUpdateConnection_edges(ctx context.Context, field graphql.CollectedField, obj *types.MailingListUpdateConnection) (ret graphql.Marshaler) {
return graphql.ResolveField(
ctx,
ec.OperationContext,
field,
ec.fieldContext_MailingListUpdateConnection_edges,
func(ctx context.Context) (any, error) {
return obj.Edges, nil
},
func(ctx context.Context, next graphql.Resolver) graphql.Resolver {
directive0 := next
directive1 := func(ctx context.Context) (any, error) {
if ec.Directives.Nda == nil {
var zeroVal []*types.MailingListUpdateEdge
return zeroVal, errors.New("directive nda is not implemented")
}
return ec.Directives.Nda(ctx, obj, directive0)
}
next = directive1
return next
},
ec.marshalNMailingListUpdateEdge2ᚕᚖgoᚗproboᚗincᚋproboᚋpkgᚋserverᚋapiᚋtrustᚋv1ᚋtypesᚐMailingListUpdateEdgeᚄ,
true,
true,
)
}
func (ec *executionContext) fieldContext_MailingListUpdateConnection_edges(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "MailingListUpdateConnection",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
switch field.Name {
case "cursor":
return ec.fieldContext_MailingListUpdateEdge_cursor(ctx, field)
case "node":
return ec.fieldContext_MailingListUpdateEdge_node(ctx, field)
}
return nil, fmt.Errorf("no field named %q was found under type MailingListUpdateEdge", field.Name)
},
}
return fc, nil
}
func (ec *executionContext) _MailingListUpdateConnection_pageInfo(ctx context.Context, field graphql.CollectedField, obj *types.MailingListUpdateConnection) (ret graphql.Marshaler) {
return graphql.ResolveField(
ctx,
ec.OperationContext,
field,
ec.fieldContext_MailingListUpdateConnection_pageInfo,
func(ctx context.Context) (any, error) {
return obj.PageInfo, nil
},
nil,
ec.marshalNPageInfo2ᚖgoᚗproboᚗincᚋproboᚋpkgᚋserverᚋapiᚋtrustᚋv1ᚋtypesᚐPageInfo,
true,
true,
)
}
func (ec *executionContext) fieldContext_MailingListUpdateConnection_pageInfo(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "MailingListUpdateConnection",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
switch field.Name {
case "hasNextPage":
return ec.fieldContext_PageInfo_hasNextPage(ctx, field)
case "hasPreviousPage":
return ec.fieldContext_PageInfo_hasPreviousPage(ctx, field)
case "startCursor":
return ec.fieldContext_PageInfo_startCursor(ctx, field)
case "endCursor":
return ec.fieldContext_PageInfo_endCursor(ctx, field)
}
return nil, fmt.Errorf("no field named %q was found under type PageInfo", field.Name)
},
}
return fc, nil
}
func (ec *executionContext) _MailingListUpdateEdge_cursor(ctx context.Context, field graphql.CollectedField, obj *types.MailingListUpdateEdge) (ret graphql.Marshaler) {
return graphql.ResolveField(
ctx,
ec.OperationContext,
field,
ec.fieldContext_MailingListUpdateEdge_cursor,
func(ctx context.Context) (any, error) {
return obj.Cursor, nil
},
nil,
ec.marshalNCursorKey2goᚗproboᚗincᚋproboᚋpkgᚋpageᚐCursorKey,
true,
true,
)
}
func (ec *executionContext) fieldContext_MailingListUpdateEdge_cursor(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "MailingListUpdateEdge",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type CursorKey does not have child fields")
},
}
return fc, nil
}
func (ec *executionContext) _MailingListUpdateEdge_node(ctx context.Context, field graphql.CollectedField, obj *types.MailingListUpdateEdge) (ret graphql.Marshaler) {
return graphql.ResolveField(
ctx,
ec.OperationContext,
field,
ec.fieldContext_MailingListUpdateEdge_node,
func(ctx context.Context) (any, error) {
return obj.Node, nil
},
func(ctx context.Context, next graphql.Resolver) graphql.Resolver {
directive0 := next
directive1 := func(ctx context.Context) (any, error) {
if ec.Directives.Nda == nil {
var zeroVal *types.MailingListUpdate
return zeroVal, errors.New("directive nda is not implemented")
}
return ec.Directives.Nda(ctx, obj, directive0)
}
next = directive1
return next
},
ec.marshalNMailingListUpdate2ᚖgoᚗproboᚗincᚋproboᚋpkgᚋserverᚋapiᚋtrustᚋv1ᚋtypesᚐMailingListUpdate,
true,
true,
)
}
func (ec *executionContext) fieldContext_MailingListUpdateEdge_node(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "MailingListUpdateEdge",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
switch field.Name {
case "id":
return ec.fieldContext_MailingListUpdate_id(ctx, field)
case "title":
return ec.fieldContext_MailingListUpdate_title(ctx, field)
case "body":
return ec.fieldContext_MailingListUpdate_body(ctx, field)
case "updatedAt":
return ec.fieldContext_MailingListUpdate_updatedAt(ctx, field)
}
return nil, fmt.Errorf("no field named %q was found under type MailingListUpdate", field.Name)
},
}
return fc, nil
}
func (ec *executionContext) _Mutation_sendMagicLink(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
return graphql.ResolveField(
ctx,
@@ -6423,6 +6875,8 @@ func (ec *executionContext) fieldContext_Query_currentTrustCenter(_ context.Cont
return ec.fieldContext_TrustCenter_complianceFrameworks(ctx, field)
case "externalUrls":
return ec.fieldContext_TrustCenter_externalUrls(ctx, field)
case "updates":
return ec.fieldContext_TrustCenter_updates(ctx, field)
}
return nil, fmt.Errorf("no field named %q was found under type TrustCenter", field.Name)
},
@@ -6945,6 +7399,8 @@ func (ec *executionContext) fieldContext_SubscribeToMailingListPayload_subscript
switch field.Name {
case "id":
return ec.fieldContext_MailingListSubscriber_id(ctx, field)
case "fullName":
return ec.fieldContext_MailingListSubscriber_fullName(ctx, field)
case "email":
return ec.fieldContext_MailingListSubscriber_email(ctx, field)
case "status":
@@ -7168,6 +7624,8 @@ func (ec *executionContext) fieldContext_TrustCenter_viewerSubscription(_ contex
switch field.Name {
case "id":
return ec.fieldContext_MailingListSubscriber_id(ctx, field)
case "fullName":
return ec.fieldContext_MailingListSubscriber_fullName(ctx, field)
case "email":
return ec.fieldContext_MailingListSubscriber_email(ctx, field)
case "status":
@@ -7624,6 +8082,66 @@ func (ec *executionContext) fieldContext_TrustCenter_externalUrls(ctx context.Co
return fc, nil
}
func (ec *executionContext) _TrustCenter_updates(ctx context.Context, field graphql.CollectedField, obj *types.TrustCenter) (ret graphql.Marshaler) {
return graphql.ResolveField(
ctx,
ec.OperationContext,
field,
ec.fieldContext_TrustCenter_updates,
func(ctx context.Context) (any, error) {
fc := graphql.GetFieldContext(ctx)
return ec.Resolvers.TrustCenter().Updates(ctx, obj, fc.Args["first"].(*int), fc.Args["after"].(*page.CursorKey), fc.Args["last"].(*int), fc.Args["before"].(*page.CursorKey))
},
func(ctx context.Context, next graphql.Resolver) graphql.Resolver {
directive0 := next
directive1 := func(ctx context.Context) (any, error) {
if ec.Directives.Nda == nil {
var zeroVal *types.MailingListUpdateConnection
return zeroVal, errors.New("directive nda is not implemented")
}
return ec.Directives.Nda(ctx, obj, directive0)
}
next = directive1
return next
},
ec.marshalNMailingListUpdateConnection2ᚖgoᚗproboᚗincᚋproboᚋpkgᚋserverᚋapiᚋtrustᚋv1ᚋtypesᚐMailingListUpdateConnection,
true,
true,
)
}
func (ec *executionContext) fieldContext_TrustCenter_updates(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "TrustCenter",
Field: field,
IsMethod: true,
IsResolver: true,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
switch field.Name {
case "edges":
return ec.fieldContext_MailingListUpdateConnection_edges(ctx, field)
case "pageInfo":
return ec.fieldContext_MailingListUpdateConnection_pageInfo(ctx, field)
}
return nil, fmt.Errorf("no field named %q was found under type MailingListUpdateConnection", field.Name)
},
}
defer func() {
if r := recover(); r != nil {
err = ec.Recover(ctx, r)
ec.Error(ctx, err)
}
}()
ctx = graphql.WithFieldContext(ctx, fc)
if fc.Args, err = ec.field_TrustCenter_updates_args(ctx, field.ArgumentMap(ec.Variables)); err != nil {
ec.Error(ctx, err)
return fc, err
}
return fc, nil
}
func (ec *executionContext) _TrustCenterAccess_id(ctx context.Context, field graphql.CollectedField, obj *types.TrustCenterAccess) (ret graphql.Marshaler) {
return graphql.ResolveField(
ctx,
@@ -10701,6 +11219,13 @@ func (ec *executionContext) _Node(ctx context.Context, sel ast.SelectionSet, obj
return graphql.Null
}
return ec._Organization(ctx, sel, obj)
case types.MailingListUpdate:
return ec._MailingListUpdate(ctx, sel, &obj)
case *types.MailingListUpdate:
if obj == nil {
return graphql.Null
}
return ec._MailingListUpdate(ctx, sel, obj)
case types.MailingListSubscriber:
return ec._MailingListSubscriber(ctx, sel, &obj)
case *types.MailingListSubscriber:
@@ -11944,6 +12469,11 @@ func (ec *executionContext) _MailingListSubscriber(ctx context.Context, sel ast.
if out.Values[i] == graphql.Null {
out.Invalids++
}
case "fullName":
out.Values[i] = ec._MailingListSubscriber_fullName(ctx, field, obj)
if out.Values[i] == graphql.Null {
out.Invalids++
}
case "email":
out.Values[i] = ec._MailingListSubscriber_email(ctx, field, obj)
if out.Values[i] == graphql.Null {
@@ -11987,6 +12517,148 @@ func (ec *executionContext) _MailingListSubscriber(ctx context.Context, sel ast.
return out
}
var mailingListUpdateImplementors = []string{"MailingListUpdate", "Node"}
func (ec *executionContext) _MailingListUpdate(ctx context.Context, sel ast.SelectionSet, obj *types.MailingListUpdate) graphql.Marshaler {
fields := graphql.CollectFields(ec.OperationContext, sel, mailingListUpdateImplementors)
out := graphql.NewFieldSet(fields)
deferred := make(map[string]*graphql.FieldSet)
for i, field := range fields {
switch field.Name {
case "__typename":
out.Values[i] = graphql.MarshalString("MailingListUpdate")
case "id":
out.Values[i] = ec._MailingListUpdate_id(ctx, field, obj)
if out.Values[i] == graphql.Null {
out.Invalids++
}
case "title":
out.Values[i] = ec._MailingListUpdate_title(ctx, field, obj)
if out.Values[i] == graphql.Null {
out.Invalids++
}
case "body":
out.Values[i] = ec._MailingListUpdate_body(ctx, field, obj)
if out.Values[i] == graphql.Null {
out.Invalids++
}
case "updatedAt":
out.Values[i] = ec._MailingListUpdate_updatedAt(ctx, field, obj)
if out.Values[i] == graphql.Null {
out.Invalids++
}
default:
panic("unknown field " + strconv.Quote(field.Name))
}
}
out.Dispatch(ctx)
if out.Invalids > 0 {
return graphql.Null
}
atomic.AddInt32(&ec.Deferred, int32(len(deferred)))
for label, dfs := range deferred {
ec.ProcessDeferredGroup(graphql.DeferredGroup{
Label: label,
Path: graphql.GetPath(ctx),
FieldSet: dfs,
Context: ctx,
})
}
return out
}
var mailingListUpdateConnectionImplementors = []string{"MailingListUpdateConnection"}
func (ec *executionContext) _MailingListUpdateConnection(ctx context.Context, sel ast.SelectionSet, obj *types.MailingListUpdateConnection) graphql.Marshaler {
fields := graphql.CollectFields(ec.OperationContext, sel, mailingListUpdateConnectionImplementors)
out := graphql.NewFieldSet(fields)
deferred := make(map[string]*graphql.FieldSet)
for i, field := range fields {
switch field.Name {
case "__typename":
out.Values[i] = graphql.MarshalString("MailingListUpdateConnection")
case "edges":
out.Values[i] = ec._MailingListUpdateConnection_edges(ctx, field, obj)
if out.Values[i] == graphql.Null {
out.Invalids++
}
case "pageInfo":
out.Values[i] = ec._MailingListUpdateConnection_pageInfo(ctx, field, obj)
if out.Values[i] == graphql.Null {
out.Invalids++
}
default:
panic("unknown field " + strconv.Quote(field.Name))
}
}
out.Dispatch(ctx)
if out.Invalids > 0 {
return graphql.Null
}
atomic.AddInt32(&ec.Deferred, int32(len(deferred)))
for label, dfs := range deferred {
ec.ProcessDeferredGroup(graphql.DeferredGroup{
Label: label,
Path: graphql.GetPath(ctx),
FieldSet: dfs,
Context: ctx,
})
}
return out
}
var mailingListUpdateEdgeImplementors = []string{"MailingListUpdateEdge"}
func (ec *executionContext) _MailingListUpdateEdge(ctx context.Context, sel ast.SelectionSet, obj *types.MailingListUpdateEdge) graphql.Marshaler {
fields := graphql.CollectFields(ec.OperationContext, sel, mailingListUpdateEdgeImplementors)
out := graphql.NewFieldSet(fields)
deferred := make(map[string]*graphql.FieldSet)
for i, field := range fields {
switch field.Name {
case "__typename":
out.Values[i] = graphql.MarshalString("MailingListUpdateEdge")
case "cursor":
out.Values[i] = ec._MailingListUpdateEdge_cursor(ctx, field, obj)
if out.Values[i] == graphql.Null {
out.Invalids++
}
case "node":
out.Values[i] = ec._MailingListUpdateEdge_node(ctx, field, obj)
if out.Values[i] == graphql.Null {
out.Invalids++
}
default:
panic("unknown field " + strconv.Quote(field.Name))
}
}
out.Dispatch(ctx)
if out.Invalids > 0 {
return graphql.Null
}
atomic.AddInt32(&ec.Deferred, int32(len(deferred)))
for label, dfs := range deferred {
ec.ProcessDeferredGroup(graphql.DeferredGroup{
Label: label,
Path: graphql.GetPath(ctx),
FieldSet: dfs,
Context: ctx,
})
}
return out
}
var mutationImplementors = []string{"Mutation"}
func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) graphql.Marshaler {
@@ -13285,6 +13957,42 @@ func (ec *executionContext) _TrustCenter(ctx context.Context, sel ast.SelectionS
continue
}
out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) })
case "updates":
field := field
innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
}
}()
res = ec._TrustCenter_updates(ctx, field, obj)
if res == graphql.Null {
atomic.AddUint32(&fs.Invalids, 1)
}
return res
}
if field.Deferrable != nil {
dfs, ok := deferred[field.Deferrable.Label]
di := 0
if ok {
dfs.AddField(field)
di = len(dfs.Values) - 1
} else {
dfs = graphql.NewFieldSet([]graphql.CollectedField{field})
deferred[field.Deferrable.Label] = dfs
}
dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler {
return innerFunc(ctx, dfs)
})
// don't run the out.Concurrently() call below
out.Values[i] = graphql.Null
continue
}
out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) })
default:
panic("unknown field " + strconv.Quote(field.Name))
@@ -15532,6 +16240,56 @@ var (
}
)
func (ec *executionContext) marshalNMailingListUpdate2ᚖgoᚗproboᚗincᚋproboᚋpkgᚋserverᚋapiᚋtrustᚋv1ᚋtypesᚐMailingListUpdate(ctx context.Context, sel ast.SelectionSet, v *types.MailingListUpdate) graphql.Marshaler {
if v == nil {
if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow")
}
return graphql.Null
}
return ec._MailingListUpdate(ctx, sel, v)
}
func (ec *executionContext) marshalNMailingListUpdateConnection2goᚗproboᚗincᚋproboᚋpkgᚋserverᚋapiᚋtrustᚋv1ᚋtypesᚐMailingListUpdateConnection(ctx context.Context, sel ast.SelectionSet, v types.MailingListUpdateConnection) graphql.Marshaler {
return ec._MailingListUpdateConnection(ctx, sel, &v)
}
func (ec *executionContext) marshalNMailingListUpdateConnection2ᚖgoᚗproboᚗincᚋproboᚋpkgᚋserverᚋapiᚋtrustᚋv1ᚋtypesᚐMailingListUpdateConnection(ctx context.Context, sel ast.SelectionSet, v *types.MailingListUpdateConnection) graphql.Marshaler {
if v == nil {
if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow")
}
return graphql.Null
}
return ec._MailingListUpdateConnection(ctx, sel, v)
}
func (ec *executionContext) marshalNMailingListUpdateEdge2ᚕᚖgoᚗproboᚗincᚋproboᚋpkgᚋserverᚋapiᚋtrustᚋv1ᚋtypesᚐMailingListUpdateEdgeᚄ(ctx context.Context, sel ast.SelectionSet, v []*types.MailingListUpdateEdge) graphql.Marshaler {
ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler {
fc := graphql.GetFieldContext(ctx)
fc.Result = &v[i]
return ec.marshalNMailingListUpdateEdge2ᚖgoᚗproboᚗincᚋproboᚋpkgᚋserverᚋapiᚋtrustᚋv1ᚋtypesᚐMailingListUpdateEdge(ctx, sel, v[i])
})
for _, e := range ret {
if e == graphql.Null {
return graphql.Null
}
}
return ret
}
func (ec *executionContext) marshalNMailingListUpdateEdge2ᚖgoᚗproboᚗincᚋproboᚋpkgᚋserverᚋapiᚋtrustᚋv1ᚋtypesᚐMailingListUpdateEdge(ctx context.Context, sel ast.SelectionSet, v *types.MailingListUpdateEdge) graphql.Marshaler {
if v == nil {
if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow")
}
return graphql.Null
}
return ec._MailingListUpdateEdge(ctx, sel, v)
}
func (ec *executionContext) marshalNNode2goᚗproboᚗincᚋproboᚋpkgᚋserverᚋapiᚋtrustᚋv1ᚋtypesᚐNode(ctx context.Context, sel ast.SelectionSet, v types.Node) graphql.Marshaler {
if v == nil {
if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {

View File

@@ -24,6 +24,7 @@ import (
type MailingListSubscriber struct {
ID gid.GID `json:"id"`
FullName string `json:"fullName"`
Email mail.Addr `json:"email"`
Status coredata.MailingListSubscriberStatus `json:"status"`
CreatedAt time.Time `json:"createdAt"`
@@ -36,6 +37,7 @@ func (m MailingListSubscriber) GetID() gid.GID { return m.ID }
func NewMailingListSubscriber(s *coredata.MailingListSubscriber) *MailingListSubscriber {
return &MailingListSubscriber{
ID: s.ID,
FullName: s.FullName,
Email: s.Email,
Status: s.Status,
CreatedAt: s.CreatedAt,

View File

@@ -0,0 +1,50 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package types
import (
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/page"
)
func NewMailingListUpdate(mlu *coredata.MailingListUpdate) *MailingListUpdate {
return &MailingListUpdate{
ID: mlu.ID,
Title: mlu.Title,
Body: mlu.Body,
UpdatedAt: mlu.UpdatedAt,
}
}
func NewMailingListUpdateEdge(mlu *coredata.MailingListUpdate) *MailingListUpdateEdge {
return &MailingListUpdateEdge{
Cursor: mlu.CursorKey(coredata.MailingListUpdateOrderFieldUpdatedAt),
Node: NewMailingListUpdate(mlu),
}
}
func NewMailingListUpdateConnection(
p *page.Page[*coredata.MailingListUpdate, coredata.MailingListUpdateOrderField],
) *MailingListUpdateConnection {
edges := make([]*MailingListUpdateEdge, len(p.Data))
for i, mlu := range p.Data {
edges[i] = NewMailingListUpdateEdge(mlu)
}
return &MailingListUpdateConnection{
Edges: edges,
PageInfo: NewPageInfo(p),
}
}

View File

@@ -153,6 +153,26 @@ type Identity struct {
func (Identity) IsNode() {}
func (this Identity) GetID() gid.GID { return this.ID }
type MailingListUpdate struct {
ID gid.GID `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (MailingListUpdate) IsNode() {}
func (this MailingListUpdate) GetID() gid.GID { return this.ID }
type MailingListUpdateConnection struct {
Edges []*MailingListUpdateEdge `json:"edges"`
PageInfo *PageInfo `json:"pageInfo"`
}
type MailingListUpdateEdge struct {
Cursor page.CursorKey `json:"cursor"`
Node *MailingListUpdate `json:"node"`
}
type Mutation struct {
}
@@ -261,6 +281,7 @@ type TrustCenter struct {
TrustCenterFiles *TrustCenterFileConnection `json:"trustCenterFiles"`
ComplianceFrameworks *ComplianceFrameworkConnection `json:"complianceFrameworks"`
ExternalUrls *ComplianceExternalURLConnection `json:"externalUrls"`
Updates *MailingListUpdateConnection `json:"updates"`
}
func (TrustCenter) IsNode() {}

View File

@@ -704,18 +704,18 @@ func (r *mutationResolver) SubscribeToMailingList(ctx context.Context) (*types.S
identity := authn.IdentityFromContext(ctx)
subscriber, err := r.mailman.CreateSubscriber(ctx, *trustCenter.MailingListID, identity.EmailAddress, identity.FullName)
subscriber, err := r.mailman.CreateSubscriber(
ctx,
*trustCenter.MailingListID,
identity.EmailAddress,
identity.FullName,
)
if err != nil {
if errors.Is(err, mailman.ErrSubscriberAlreadyExist) {
subscriber, err = r.mailman.GetSubscriber(ctx, *trustCenter.MailingListID, identity.EmailAddress)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot get existing mailing list subscription", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
} else {
r.logger.ErrorCtx(ctx, "cannot subscribe to mailing list", log.Error(err))
return nil, gqlutils.Internal(ctx)
return nil, gqlutils.Conflictf(ctx, "already subscribed to this mailing list")
}
r.logger.ErrorCtx(ctx, "cannot subscribe to mailing list", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.SubscribeToMailingListPayload{
@@ -738,10 +738,13 @@ func (r *mutationResolver) UnsubscribeFromMailingList(ctx context.Context) (*typ
return nil, gqlutils.Internal(ctx)
}
if subscriber == nil {
return nil, gqlutils.NotFoundf(ctx, "mailing list subscription not found")
return nil, gqlutils.NotFoundf(ctx, "not subscribed to this mailing list")
}
if err := r.mailman.DeleteSubscriber(ctx, subscriber.ID); err != nil {
if errors.Is(err, mailman.ErrSubscriberNotFound) {
return nil, gqlutils.NotFoundf(ctx, "not subscribed to this mailing list")
}
r.logger.ErrorCtx(ctx, "cannot unsubscribe from mailing list", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
@@ -1207,6 +1210,35 @@ func (r *trustCenterResolver) ExternalUrls(ctx context.Context, obj *types.Trust
return types.NewComplianceExternalURLConnection(result), nil
}
// Updates is the resolver for the updates field.
func (r *trustCenterResolver) Updates(ctx context.Context, obj *types.TrustCenter, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.MailingListUpdateConnection, error) {
trustService := r.TrustService(ctx, obj.ID.TenantID())
tc, err := trustService.TrustCenters.Get(ctx, obj.ID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot load trust center", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
if tc.MailingListID == nil {
return &types.MailingListUpdateConnection{Edges: []*types.MailingListUpdateEdge{}, PageInfo: &types.PageInfo{}}, nil
}
pageOrderBy := page.OrderBy[coredata.MailingListUpdateOrderField]{
Field: coredata.MailingListUpdateOrderFieldUpdatedAt,
Direction: page.OrderDirectionDesc,
}
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
result, err := r.mailman.ListSentMailingListUpdates(ctx, *tc.MailingListID, cursor)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot list mailing list updates", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewMailingListUpdateConnection(result), nil
}
// IsUserAuthorized is the resolver for the isUserAuthorized field.
func (r *trustCenterFileResolver) IsUserAuthorized(ctx context.Context, obj *types.TrustCenterFile) (bool, error) {
trustService := r.TrustService(ctx, obj.ID.TenantID())

View File

@@ -0,0 +1,124 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package mailactions
import (
"errors"
"html/template"
"net/http"
"net/url"
"go.probo.inc/probo/pkg/mailman"
)
func confirmGetHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
if token == "" {
renderPage(
w,
http.StatusBadRequest,
page{
Title: "Invalid link",
Heading: "Invalid link",
Body: "This confirmation link is missing required information. Please use the link from your email.",
},
)
return
}
renderPage(
w,
http.StatusOK,
page{
Title: "Confirm subscription",
Heading: "Confirm your subscription",
Body: "Click the button below to confirm that you want to receive updates.",
Form: &form{
ActionURL: template.URL("?token=" + url.QueryEscape(token)),
Button: "Confirm subscription",
Danger: false,
},
},
)
}
}
func confirmPostHandler(mailmanSvc *mailman.Service, tokenSecret string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
if token == "" {
renderPage(
w,
http.StatusBadRequest,
page{
Title: "Invalid link",
Heading: "Invalid link",
Body: "This confirmation link is missing required information. Please use the link from your email.",
},
)
return
}
data, err := mailman.ValidateConfirmToken(tokenSecret, token)
if err != nil {
renderPage(
w,
http.StatusUnauthorized,
page{
Title: "Invalid link",
Heading: "Invalid or expired link",
Body: "This confirmation link is invalid or has expired. Confirmation links are valid for 30 days — please re-subscribe to get a new one.",
},
)
return
}
if err := mailmanSvc.ConfirmSubscriberByEmail(r.Context(), data.MailingListID, data.Email); err != nil {
if errors.Is(err, mailman.ErrSubscriberNotFound) {
renderPage(
w,
http.StatusNotFound, page{
Title: "Not found",
Heading: "Subscription not found",
Body: "We could not find your subscription. It may have already been cancelled or this link was already used.",
},
)
return
}
renderPage(
w,
http.StatusInternalServerError,
page{
Title: "Something went wrong",
Heading: "Something went wrong",
Body: "We could not confirm your subscription. Please try again later.",
},
)
return
}
renderPage(
w,
http.StatusOK,
page{
Title: "Subscription confirmed",
Heading: "Subscription confirmed",
Body: "You're now subscribed and will receive updates.",
},
)
}
}

View File

@@ -0,0 +1,40 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
// Package mailactions provides HTTP handlers for mailing list subscription
// management. All routes are mounted at /mail-actions/ with no API version
// prefix so they can be linked directly from emails and bookmarked by users.
//
// GET /mail-actions/unsubscribe shows an unsubscribe confirmation page
// POST /mail-actions/unsubscribe RFC 8058 one-click unsubscribe
// GET /mail-actions/confirm shows a subscription confirmation page
// POST /mail-actions/confirm confirms a pending subscription
package mailactions
import (
"github.com/go-chi/chi/v5"
"go.probo.inc/probo/pkg/mailman"
)
func NewMux(mailmanSvc *mailman.Service, tokenSecret string) *chi.Mux {
r := chi.NewMux()
r.Get("/unsubscribe", unsubscribeGetHandler())
r.Post("/unsubscribe", unsubscribePostHandler(mailmanSvc, tokenSecret))
r.Get("/confirm", confirmGetHandler())
r.Post("/confirm", confirmPostHandler(mailmanSvc, tokenSecret))
return r
}

View File

@@ -0,0 +1,45 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package mailactions
import (
_ "embed"
"html/template"
"net/http"
)
//go:embed templates/page.html.tmpl
var pageTmplHTML string
type form struct {
ActionURL template.URL
Button string
Danger bool
}
type page struct {
Title string
Heading string
Body string
Form *form
}
var tmpl = template.Must(template.New("page.html.tmpl").Parse(pageTmplHTML))
func renderPage(w http.ResponseWriter, status int, p page) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
_ = tmpl.Execute(w, p)
}

View File

@@ -0,0 +1,99 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{.Title}}</title>
<style>
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: #f3f4f6;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
color: #111827;
}
.card {
background: #fff;
border-radius: 0.75rem;
border: 1px solid #e5e7eb;
padding: 2.5rem 2rem;
max-width: 26rem;
width: 100%;
text-align: center;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
h1 {
font-size: 1.25rem;
font-weight: 600;
line-height: 1.4;
margin-bottom: 0.625rem;
}
p {
font-size: 0.9375rem;
color: #6b7280;
line-height: 1.6;
margin-top: 0.5rem;
}
form {
margin-top: 1.75rem;
}
button {
font-family: inherit;
font-size: 0.9375rem;
font-weight: 500;
padding: 0.625rem 1.5rem;
border-radius: 0.5rem;
border: none;
cursor: pointer;
line-height: 1.5;
}
.primary {
background: #111827;
color: #fff;
}
.primary:hover {
background: #1f2937;
}
.danger {
background: #dc2626;
color: #fff;
}
.danger:hover {
background: #b91c1c;
}
</style>
</head>
<body>
<div class="card">
<h1>{{.Heading}}</h1>
<p>{{.Body}}</p>
{{- if .Form}}
<form method="POST" action="{{.Form.ActionURL}}">
<button type="submit" class="{{if .Form.Danger}}danger{{else}}primary{{end}}">
{{.Form.Button}}
</button>
</form>
{{- end}}
</div>
</body>
</html>

View File

@@ -0,0 +1,116 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package mailactions
import (
"errors"
"html/template"
"net/http"
"net/url"
"go.probo.inc/probo/pkg/mailman"
)
func unsubscribeGetHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
if token == "" {
renderPage(
w,
http.StatusBadRequest,
page{
Title: "Invalid link",
Heading: "Invalid link",
Body: "This unsubscribe link is missing required information. Please use the link from your email.",
},
)
return
}
renderPage(
w,
http.StatusOK,
page{
Title: "Unsubscribe",
Heading: "Unsubscribe from mailing list",
Body: "Click the button below to confirm that you no longer want to receive updates.",
Form: &form{
ActionURL: template.URL("?token=" + url.QueryEscape(token)),
Button: "Confirm unsubscribe",
Danger: true,
},
},
)
}
}
func unsubscribePostHandler(mailmanSvc *mailman.Service, tokenSecret string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
if token == "" {
renderPage(
w,
http.StatusBadRequest,
page{
Title: "Invalid link",
Heading: "Invalid link",
Body: "This unsubscribe link is missing required information. Please use the link from your email.",
},
)
return
}
data, err := mailman.ValidateUnsubscribeToken(tokenSecret, token)
if err != nil {
renderPage(
w,
http.StatusUnauthorized,
page{
Title: "Invalid link",
Heading: "Invalid or expired link",
Body: "This unsubscribe link is invalid or has expired.",
},
)
return
}
if err := mailmanSvc.UnsubscribeByEmail(r.Context(), data.MailingListID, data.Email); err != nil {
if !errors.Is(err, mailman.ErrSubscriberNotFound) {
renderPage(
w,
http.StatusInternalServerError,
page{
Title: "Something went wrong",
Heading: "Something went wrong",
Body: "We could not process your request. Please try again later.",
},
)
return
}
}
// Also success when already unsubscribed — unsubscribe is idempotent
// per RFC 8058.
renderPage(
w,
http.StatusOK,
page{
Title: "Unsubscribed",
Heading: "You've been unsubscribed",
Body: "You will no longer receive updates.",
},
)
}
}

View File

@@ -32,6 +32,7 @@ import (
"go.probo.inc/probo/pkg/securecookie"
"go.probo.inc/probo/pkg/server/api"
"go.probo.inc/probo/pkg/server/api/compliancepage"
"go.probo.inc/probo/pkg/server/mailactions"
trust_web "go.probo.inc/probo/pkg/server/trust"
console_web "go.probo.inc/probo/pkg/server/web"
"go.probo.inc/probo/pkg/slack"
@@ -57,14 +58,15 @@ type Config struct {
}
type Server struct {
apiServer *api.Server
consoleWebServer *console_web.Server
trustWebServer *trust_web.Server
router *chi.Mux
extraHeaderFields map[string]string
proboService *probo.Service
trustService *trust.Service
logger *log.Logger
apiServer *api.Server
mailActionsHandler http.Handler
consoleWebServer *console_web.Server
trustWebServer *trust_web.Server
router *chi.Mux
extraHeaderFields map[string]string
proboService *probo.Service
trustService *trust.Service
logger *log.Logger
}
func NewServer(cfg Config) (*Server, error) {
@@ -102,14 +104,15 @@ func NewServer(cfg Config) (*Server, error) {
router := chi.NewRouter()
server := &Server{
apiServer: apiServer,
consoleWebServer: consoleWebServer,
trustWebServer: trustWebServer,
router: router,
extraHeaderFields: cfg.ExtraHeaderFields,
proboService: cfg.Probo,
trustService: cfg.Trust,
logger: cfg.Logger,
apiServer: apiServer,
mailActionsHandler: mailactions.NewMux(cfg.Mailman, cfg.TokenSecret),
consoleWebServer: consoleWebServer,
trustWebServer: trustWebServer,
router: router,
extraHeaderFields: cfg.ExtraHeaderFields,
proboService: cfg.Probo,
trustService: cfg.Trust,
logger: cfg.Logger,
}
server.setupRoutes(cfg.BaseURL.String())
@@ -119,6 +122,7 @@ func NewServer(cfg Config) (*Server, error) {
func (s *Server) setupRoutes(baseURL string) {
s.router.Mount("/api", http.StripPrefix("/api", s.apiServer))
s.router.Mount("/mail-actions", http.StripPrefix("/mail-actions", s.mailActionsHandler))
s.router.Route("/trust/{slugOrId}", func(r chi.Router) {
r.Use(compliancepage.NewIDMiddleware(s.trustService, baseURL))

View File

@@ -571,6 +571,7 @@ func (s *TrustCenterAccessService) sendAccessEmail(ctx context.Context, tx pg.Co
subject,
textBody,
htmlBody,
nil,
)
if err := accessEmail.Insert(ctx, tx); err != nil {
@@ -705,6 +706,7 @@ func (s *TrustCenterAccessService) sendDocumentAccessRejectedEmail(
subject,
textBody,
htmlBody,
nil,
)
if err := accessEmail.Insert(ctx, tx); err != nil {