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

@@ -1,5 +1,5 @@
/** /**
* @generated SignedSource<<7e30e59b53799ccae49199d7b46f0320>> * @generated SignedSource<<1ddc7eb54e71de0d95fb5af865fc0348>>
* @lightSyntaxTransform * @lightSyntaxTransform
* @nogrep * @nogrep
*/ */
@@ -21,6 +21,7 @@ export type CompliancePageMailingListPageQuery$data = {
readonly mailingList: { readonly mailingList: {
readonly id: string; readonly id: string;
readonly replyTo: string | null | undefined; readonly replyTo: string | null | undefined;
readonly " $fragmentSpreads": FragmentRefs<"CompliancePageUpdatesListFragment">;
} | null | undefined; } | null | undefined;
readonly " $fragmentSpreads": FragmentRefs<"CompliancePageMailingListFragment">; readonly " $fragmentSpreads": FragmentRefs<"CompliancePageMailingListFragment">;
}; };
@@ -77,7 +78,65 @@ v5 = [
"name": "first", "name": "first",
"value": 20 "value": 20
} }
]; ],
v6 = {
"alias": null,
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
}
],
"storageKey": null
},
v7 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "status",
"storageKey": null
},
v8 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "createdAt",
"storageKey": null
},
v9 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
},
v10 = {
"kind": "ClientExtension",
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__id",
"storageKey": null
}
]
};
return { return {
"fragment": { "fragment": {
"argumentDefinitions": (v0/*: any*/), "argumentDefinitions": (v0/*: any*/),
@@ -117,7 +176,12 @@ return {
"plural": false, "plural": false,
"selections": [ "selections": [
(v3/*: any*/), (v3/*: any*/),
(v4/*: any*/) (v4/*: any*/),
{
"args": null,
"kind": "FragmentSpread",
"name": "CompliancePageUpdatesListFragment"
}
], ],
"storageKey": null "storageKey": null
}, },
@@ -179,6 +243,76 @@ return {
"selections": [ "selections": [
(v3/*: any*/), (v3/*: any*/),
(v4/*: any*/), (v4/*: any*/),
{
"alias": null,
"args": (v5/*: any*/),
"concreteType": "MailingListUpdateConnection",
"kind": "LinkedField",
"name": "updates",
"plural": false,
"selections": [
(v6/*: any*/),
{
"alias": null,
"args": null,
"concreteType": "MailingListUpdateEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "MailingListUpdate",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v3/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "title",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "body",
"storageKey": null
},
(v7/*: any*/),
(v8/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "updatedAt",
"storageKey": null
},
(v2/*: any*/)
],
"storageKey": null
},
(v9/*: any*/)
],
"storageKey": null
},
(v10/*: any*/)
],
"storageKey": "updates(first:20)"
},
{
"alias": null,
"args": (v5/*: any*/),
"filters": null,
"handle": "connection",
"key": "CompliancePageUpdatesList_updates",
"kind": "LinkedHandle",
"name": "updates"
},
{ {
"alias": null, "alias": null,
"args": (v5/*: any*/), "args": (v5/*: any*/),
@@ -187,31 +321,7 @@ return {
"name": "subscribers", "name": "subscribers",
"plural": false, "plural": false,
"selections": [ "selections": [
{ (v6/*: any*/),
"alias": null,
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
}
],
"storageKey": null
},
{ {
"alias": null, "alias": null,
"args": null, "args": null,
@@ -243,46 +353,17 @@ return {
"name": "email", "name": "email",
"storageKey": null "storageKey": null
}, },
{ (v7/*: any*/),
"alias": null, (v8/*: any*/),
"args": null,
"kind": "ScalarField",
"name": "status",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "createdAt",
"storageKey": null
},
(v2/*: any*/) (v2/*: any*/)
], ],
"storageKey": null "storageKey": null
}, },
{ (v9/*: any*/)
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
}
], ],
"storageKey": null "storageKey": null
}, },
{ (v10/*: any*/)
"kind": "ClientExtension",
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__id",
"storageKey": null
}
]
}
], ],
"storageKey": "subscribers(first:20)" "storageKey": "subscribers(first:20)"
}, },
@@ -312,16 +393,16 @@ return {
] ]
}, },
"params": { "params": {
"cacheID": "8eb57a5cf8f8b1294da6aee3228176f3", "cacheID": "c8ba2eeecdfac4115f0ab1493ecf02ce",
"id": null, "id": null,
"metadata": {}, "metadata": {},
"name": "CompliancePageMailingListPageQuery", "name": "CompliancePageMailingListPageQuery",
"operationKind": "query", "operationKind": "query",
"text": "query CompliancePageMailingListPageQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n ... on Organization {\n compliancePage: trustCenter {\n id\n mailingList {\n id\n replyTo\n }\n ...CompliancePageMailingListFragment\n }\n }\n id\n }\n}\n\nfragment CompliancePageMailingListFragment on TrustCenter {\n mailingList {\n id\n subscribers(first: 20) {\n pageInfo {\n hasNextPage\n endCursor\n }\n edges {\n node {\n id\n fullName\n email\n status\n createdAt\n __typename\n }\n cursor\n }\n }\n }\n id\n}\n" "text": "query CompliancePageMailingListPageQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n ... on Organization {\n compliancePage: trustCenter {\n id\n mailingList {\n id\n replyTo\n ...CompliancePageUpdatesListFragment\n }\n ...CompliancePageMailingListFragment\n }\n }\n id\n }\n}\n\nfragment CompliancePageMailingListFragment on TrustCenter {\n mailingList {\n id\n subscribers(first: 20) {\n pageInfo {\n hasNextPage\n endCursor\n }\n edges {\n node {\n id\n fullName\n email\n status\n createdAt\n __typename\n }\n cursor\n }\n }\n }\n id\n}\n\nfragment CompliancePageUpdatesListFragment on MailingList {\n updates(first: 20) {\n pageInfo {\n hasNextPage\n endCursor\n }\n edges {\n node {\n id\n title\n body\n status\n createdAt\n updatedAt\n __typename\n }\n cursor\n }\n }\n id\n}\n"
} }
}; };
})(); })();
(node as any).hash = "0df7b2a8b9f0d2f421f354bd56773e43"; (node as any).hash = "bda0ad6e5ff15a324d4c2ad76d0c94cd";
export default node; export default node;

View File

@@ -0,0 +1,132 @@
/**
* @generated SignedSource<<8fc725e69722e66e680df0ed52206c77>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type DeleteMailingListUpdateInput = {
id: string;
};
export type CompliancePageUpdatesListDeleteMutation$variables = {
connections: ReadonlyArray<string>;
input: DeleteMailingListUpdateInput;
};
export type CompliancePageUpdatesListDeleteMutation$data = {
readonly deleteMailingListUpdate: {
readonly deletedMailingListUpdateId: string;
};
};
export type CompliancePageUpdatesListDeleteMutation = {
response: CompliancePageUpdatesListDeleteMutation$data;
variables: CompliancePageUpdatesListDeleteMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "connections"
},
v1 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
},
v2 = [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "deletedMailingListUpdateId",
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/)
],
"kind": "Fragment",
"metadata": null,
"name": "CompliancePageUpdatesListDeleteMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "DeleteMailingListUpdatePayload",
"kind": "LinkedField",
"name": "deleteMailingListUpdate",
"plural": false,
"selections": [
(v3/*: any*/)
],
"storageKey": null
}
],
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [
(v1/*: any*/),
(v0/*: any*/)
],
"kind": "Operation",
"name": "CompliancePageUpdatesListDeleteMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "DeleteMailingListUpdatePayload",
"kind": "LinkedField",
"name": "deleteMailingListUpdate",
"plural": false,
"selections": [
(v3/*: any*/),
{
"alias": null,
"args": null,
"filters": null,
"handle": "deleteEdge",
"key": "",
"kind": "ScalarHandle",
"name": "deletedMailingListUpdateId",
"handleArgs": [
{
"kind": "Variable",
"name": "connections",
"variableName": "connections"
}
]
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "892a75687f4d8560945e5a30915aecbd",
"id": null,
"metadata": {},
"name": "CompliancePageUpdatesListDeleteMutation",
"operationKind": "mutation",
"text": "mutation CompliancePageUpdatesListDeleteMutation(\n $input: DeleteMailingListUpdateInput!\n) {\n deleteMailingListUpdate(input: $input) {\n deletedMailingListUpdateId\n }\n}\n"
}
};
})();
(node as any).hash = "29a3142fa30bc64beb74d21f5162a00c";
export default node;

View File

@@ -0,0 +1,226 @@
/**
* @generated SignedSource<<d32034602cfb0c3128ebdfaa7e26af40>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ReaderFragment } from 'relay-runtime';
export type MailingListUpdateStatus = "DRAFT" | "ENQUEUED" | "PROCESSING" | "SENT";
import { FragmentRefs } from "relay-runtime";
export type CompliancePageUpdatesListFragment$data = {
readonly id: string;
readonly updates: {
readonly __id: string;
readonly edges: ReadonlyArray<{
readonly node: {
readonly body: string;
readonly createdAt: string;
readonly id: string;
readonly status: MailingListUpdateStatus;
readonly title: string;
readonly updatedAt: string;
};
}>;
readonly pageInfo: {
readonly endCursor: string | null | undefined;
readonly hasNextPage: boolean;
};
};
readonly " $fragmentType": "CompliancePageUpdatesListFragment";
};
export type CompliancePageUpdatesListFragment$key = {
readonly " $data"?: CompliancePageUpdatesListFragment$data;
readonly " $fragmentSpreads": FragmentRefs<"CompliancePageUpdatesListFragment">;
};
import CompliancePageUpdatesListQuery_graphql from './CompliancePageUpdatesListQuery.graphql';
const node: ReaderFragment = (function(){
var v0 = [
"updates"
],
v1 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
};
return {
"argumentDefinitions": [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "after"
},
{
"defaultValue": 20,
"kind": "LocalArgument",
"name": "first"
}
],
"kind": "Fragment",
"metadata": {
"connection": [
{
"count": "first",
"cursor": "after",
"direction": "forward",
"path": (v0/*: any*/)
}
],
"refetch": {
"connection": {
"forward": {
"count": "first",
"cursor": "after"
},
"backward": null,
"path": (v0/*: any*/)
},
"fragmentPathInResult": [
"node"
],
"operation": CompliancePageUpdatesListQuery_graphql,
"identifierInfo": {
"identifierField": "id",
"identifierQueryVariableName": "id"
}
}
},
"name": "CompliancePageUpdatesListFragment",
"selections": [
{
"alias": "updates",
"args": null,
"concreteType": "MailingListUpdateConnection",
"kind": "LinkedField",
"name": "__CompliancePageUpdatesList_updates_connection",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "MailingListUpdateEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "MailingListUpdate",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v1/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "title",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "body",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "status",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "createdAt",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "updatedAt",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
}
],
"storageKey": null
},
{
"kind": "ClientExtension",
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__id",
"storageKey": null
}
]
}
],
"storageKey": null
},
(v1/*: any*/)
],
"type": "MailingList",
"abstractKey": null
};
})();
(node as any).hash = "97bcf2215cc4fa04d1d4b84b2da4c33a";
export default node;

View File

@@ -0,0 +1,270 @@
/**
* @generated SignedSource<<dcc3921bab2d3c42dc01f71fbf1a4338>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type CompliancePageUpdatesListQuery$variables = {
after?: string | null | undefined;
first?: number | null | undefined;
id: string;
};
export type CompliancePageUpdatesListQuery$data = {
readonly node: {
readonly " $fragmentSpreads": FragmentRefs<"CompliancePageUpdatesListFragment">;
};
};
export type CompliancePageUpdatesListQuery = {
response: CompliancePageUpdatesListQuery$data;
variables: CompliancePageUpdatesListQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "after"
},
{
"defaultValue": 20,
"kind": "LocalArgument",
"name": "first"
},
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "id"
}
],
v1 = [
{
"kind": "Variable",
"name": "id",
"variableName": "id"
}
],
v2 = [
{
"kind": "Variable",
"name": "after",
"variableName": "after"
},
{
"kind": "Variable",
"name": "first",
"variableName": "first"
}
],
v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
},
v4 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "CompliancePageUpdatesListQuery",
"selections": [
{
"alias": null,
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"args": (v2/*: any*/),
"kind": "FragmentSpread",
"name": "CompliancePageUpdatesListFragment"
}
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "CompliancePageUpdatesListQuery",
"selections": [
{
"alias": null,
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v3/*: any*/),
(v4/*: any*/),
{
"kind": "InlineFragment",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "MailingListUpdateConnection",
"kind": "LinkedField",
"name": "updates",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "MailingListUpdateEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "MailingListUpdate",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v4/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "title",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "body",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "status",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "createdAt",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "updatedAt",
"storageKey": null
},
(v3/*: any*/)
],
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
}
],
"storageKey": null
},
{
"kind": "ClientExtension",
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__id",
"storageKey": null
}
]
}
],
"storageKey": null
},
{
"alias": null,
"args": (v2/*: any*/),
"filters": null,
"handle": "connection",
"key": "CompliancePageUpdatesList_updates",
"kind": "LinkedHandle",
"name": "updates"
}
],
"type": "MailingList",
"abstractKey": null
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "e1a6160f157715c1c52adca86d02b9c6",
"id": null,
"metadata": {},
"name": "CompliancePageUpdatesListQuery",
"operationKind": "query",
"text": "query CompliancePageUpdatesListQuery(\n $after: CursorKey = null\n $first: Int = 20\n $id: ID!\n) {\n node(id: $id) {\n __typename\n ...CompliancePageUpdatesListFragment_2HEEH6\n id\n }\n}\n\nfragment CompliancePageUpdatesListFragment_2HEEH6 on MailingList {\n updates(first: $first, after: $after) {\n pageInfo {\n hasNextPage\n endCursor\n }\n edges {\n node {\n id\n title\n body\n status\n createdAt\n updatedAt\n __typename\n }\n cursor\n }\n }\n id\n}\n"
}
};
})();
(node as any).hash = "97bcf2215cc4fa04d1d4b84b2da4c33a";
export default node;

View File

@@ -0,0 +1,138 @@
/**
* @generated SignedSource<<f5323e8e7bd73882c9169eb01318a21c>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type MailingListUpdateStatus = "DRAFT" | "ENQUEUED" | "PROCESSING" | "SENT";
export type SendMailingListUpdateInput = {
id: string;
};
export type CompliancePageUpdatesListSendMutation$variables = {
input: SendMailingListUpdateInput;
};
export type CompliancePageUpdatesListSendMutation$data = {
readonly sendMailingListUpdate: {
readonly mailingListUpdate: {
readonly body: string;
readonly id: string;
readonly status: MailingListUpdateStatus;
readonly title: string;
readonly updatedAt: string;
};
};
};
export type CompliancePageUpdatesListSendMutation = {
response: CompliancePageUpdatesListSendMutation$data;
variables: CompliancePageUpdatesListSendMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
}
],
v1 = [
{
"alias": null,
"args": [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
"concreteType": "SendMailingListUpdatePayload",
"kind": "LinkedField",
"name": "sendMailingListUpdate",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "MailingListUpdate",
"kind": "LinkedField",
"name": "mailingListUpdate",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "title",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "body",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "status",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "updatedAt",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
];
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "CompliancePageUpdatesListSendMutation",
"selections": (v1/*: any*/),
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "CompliancePageUpdatesListSendMutation",
"selections": (v1/*: any*/)
},
"params": {
"cacheID": "23fee99a4999d2c9cbeecd6fa9863b67",
"id": null,
"metadata": {},
"name": "CompliancePageUpdatesListSendMutation",
"operationKind": "mutation",
"text": "mutation CompliancePageUpdatesListSendMutation(\n $input: SendMailingListUpdateInput!\n) {\n sendMailingListUpdate(input: $input) {\n mailingListUpdate {\n id\n title\n body\n status\n updatedAt\n }\n }\n}\n"
}
};
})();
(node as any).hash = "37c68b46d761280e2ff16f5bff08b249";
export default node;

View File

@@ -0,0 +1,140 @@
/**
* @generated SignedSource<<622eccc63036573a8e38ffc8dd36e7d7>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type MailingListUpdateStatus = "DRAFT" | "ENQUEUED" | "PROCESSING" | "SENT";
export type UpdateMailingListUpdateInput = {
body: string;
id: string;
title: string;
};
export type EditComplianceUpdateDialogMutation$variables = {
input: UpdateMailingListUpdateInput;
};
export type EditComplianceUpdateDialogMutation$data = {
readonly updateMailingListUpdate: {
readonly mailingListUpdate: {
readonly body: string;
readonly id: string;
readonly status: MailingListUpdateStatus;
readonly title: string;
readonly updatedAt: string;
};
};
};
export type EditComplianceUpdateDialogMutation = {
response: EditComplianceUpdateDialogMutation$data;
variables: EditComplianceUpdateDialogMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
}
],
v1 = [
{
"alias": null,
"args": [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
"concreteType": "UpdateMailingListUpdatePayload",
"kind": "LinkedField",
"name": "updateMailingListUpdate",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "MailingListUpdate",
"kind": "LinkedField",
"name": "mailingListUpdate",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "title",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "body",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "status",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "updatedAt",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
];
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "EditComplianceUpdateDialogMutation",
"selections": (v1/*: any*/),
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "EditComplianceUpdateDialogMutation",
"selections": (v1/*: any*/)
},
"params": {
"cacheID": "49b4c9f194a0bf6ebe01408ae6387f2e",
"id": null,
"metadata": {},
"name": "EditComplianceUpdateDialogMutation",
"operationKind": "mutation",
"text": "mutation EditComplianceUpdateDialogMutation(\n $input: UpdateMailingListUpdateInput!\n) {\n updateMailingListUpdate(input: $input) {\n mailingListUpdate {\n id\n title\n body\n status\n updatedAt\n }\n }\n}\n"
}
};
})();
(node as any).hash = "8b2246ab3bddba658cc488610afcd80f";
export default node;

View File

@@ -0,0 +1,193 @@
/**
* @generated SignedSource<<458f30b721ecd9df210621a8853008c3>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type MailingListUpdateStatus = "DRAFT" | "ENQUEUED" | "PROCESSING" | "SENT";
export type CreateMailingListUpdateInput = {
body: string;
mailingListId: string;
title: string;
};
export type NewComplianceUpdateDialogMutation$variables = {
connections: ReadonlyArray<string>;
input: CreateMailingListUpdateInput;
};
export type NewComplianceUpdateDialogMutation$data = {
readonly createMailingListUpdate: {
readonly mailingListUpdate: {
readonly body: string;
readonly createdAt: string;
readonly id: string;
readonly status: MailingListUpdateStatus;
readonly title: string;
readonly updatedAt: string;
};
};
};
export type NewComplianceUpdateDialogMutation = {
response: NewComplianceUpdateDialogMutation$data;
variables: NewComplianceUpdateDialogMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "connections"
},
v1 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
},
v2 = [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
v3 = {
"alias": null,
"args": null,
"concreteType": "MailingListUpdate",
"kind": "LinkedField",
"name": "mailingListUpdate",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "title",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "body",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "status",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "createdAt",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "updatedAt",
"storageKey": null
}
],
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/)
],
"kind": "Fragment",
"metadata": null,
"name": "NewComplianceUpdateDialogMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "CreateMailingListUpdatePayload",
"kind": "LinkedField",
"name": "createMailingListUpdate",
"plural": false,
"selections": [
(v3/*: any*/)
],
"storageKey": null
}
],
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [
(v1/*: any*/),
(v0/*: any*/)
],
"kind": "Operation",
"name": "NewComplianceUpdateDialogMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "CreateMailingListUpdatePayload",
"kind": "LinkedField",
"name": "createMailingListUpdate",
"plural": false,
"selections": [
(v3/*: any*/),
{
"alias": null,
"args": null,
"filters": null,
"handle": "prependNode",
"key": "",
"kind": "LinkedHandle",
"name": "mailingListUpdate",
"handleArgs": [
{
"kind": "Variable",
"name": "connections",
"variableName": "connections"
},
{
"kind": "Literal",
"name": "edgeTypeName",
"value": "MailingListUpdateEdge"
}
]
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "33fc392100d93803de3a189e75961a75",
"id": null,
"metadata": {},
"name": "NewComplianceUpdateDialogMutation",
"operationKind": "mutation",
"text": "mutation NewComplianceUpdateDialogMutation(\n $input: CreateMailingListUpdateInput!\n) {\n createMailingListUpdate(input: $input) {\n mailingListUpdate {\n id\n title\n body\n status\n createdAt\n updatedAt\n }\n }\n}\n"
}
};
})();
(node as any).hash = "bf02b571efaf38d4f4a8fde50b311832";
export default node;

View File

@@ -1,5 +1,5 @@
import { useTranslate } from "@probo/i18n"; import { useTranslate } from "@probo/i18n";
import { Button, Card, Field, IconPlusLarge, Spinner, useDialogRef } from "@probo/ui"; import { Button, Card, Field, IconPlusLarge, Spinner, TabItem, Tabs, useDialogRef } from "@probo/ui";
import { useState } from "react"; import { useState } from "react";
import { type PreloadedQuery, usePreloadedQuery } from "react-relay"; import { type PreloadedQuery, usePreloadedQuery } from "react-relay";
import { ConnectionHandler, graphql } from "relay-runtime"; import { ConnectionHandler, graphql } from "relay-runtime";
@@ -9,7 +9,10 @@ import type { CompliancePageMailingListPageQuery } from "#/__generated__/core/Co
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts"; import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
import { CompliancePageMailingList } from "./_components/CompliancePageMailingList"; import { CompliancePageMailingList } from "./_components/CompliancePageMailingList";
import { CompliancePageUpdatesList, type UpdateNode } from "./_components/CompliancePageUpdatesList";
import { EditComplianceUpdateDialog } from "./_components/EditComplianceUpdateDialog";
import { NewCompliancePageSubscriberDialog } from "./_components/NewCompliancePageSubscriberDialog"; import { NewCompliancePageSubscriberDialog } from "./_components/NewCompliancePageSubscriberDialog";
import { NewComplianceUpdateDialog } from "./_components/NewComplianceUpdateDialog";
export const compliancePageMailingListPageQuery = graphql` export const compliancePageMailingListPageQuery = graphql`
query CompliancePageMailingListPageQuery($organizationId: ID!) { query CompliancePageMailingListPageQuery($organizationId: ID!) {
@@ -21,6 +24,7 @@ export const compliancePageMailingListPageQuery = graphql`
mailingList { mailingList {
id id
replyTo replyTo
...CompliancePageUpdatesListFragment
} }
...CompliancePageMailingListFragment ...CompliancePageMailingListFragment
} }
@@ -40,12 +44,19 @@ const updateMailingListMutation = graphql`
} }
`; `;
type Tab = "updates" | "subscribers";
export function CompliancePageMailingListPage(props: { export function CompliancePageMailingListPage(props: {
queryRef: PreloadedQuery<CompliancePageMailingListPageQuery>; queryRef: PreloadedQuery<CompliancePageMailingListPageQuery>;
}) { }) {
const { queryRef } = props; const { queryRef } = props;
const { __ } = useTranslate(); const { __ } = useTranslate();
const dialogRef = useDialogRef(); const subscriberDialogRef = useDialogRef();
const newUpdateDialogRef = useDialogRef();
const editUpdateDialogRef = useDialogRef();
const [activeTab, setActiveTab] = useState<Tab>("updates");
const [selectedUpdate, setSelectedUpdate] = useState<UpdateNode | null>(null);
const { organization } = usePreloadedQuery<CompliancePageMailingListPageQuery>( const { organization } = usePreloadedQuery<CompliancePageMailingListPageQuery>(
compliancePageMailingListPageQuery, compliancePageMailingListPageQuery,
@@ -56,13 +67,18 @@ export function CompliancePageMailingListPage(props: {
throw new Error("invalid type for node"); throw new Error("invalid type for node");
} }
const mailingList = organization.compliancePage.mailingList; const { compliancePage } = organization;
const mailingList = compliancePage.mailingList;
const mailingListId = mailingList?.id; const mailingListId = mailingList?.id;
const connectionId = mailingListId const subscriberConnectionId = mailingListId
? ConnectionHandler.getConnectionID(mailingListId, "CompliancePageMailingList_subscribers") ? ConnectionHandler.getConnectionID(mailingListId, "CompliancePageMailingList_subscribers")
: null; : null;
const updatesConnectionId = mailingListId
? ConnectionHandler.getConnectionID(mailingListId, "CompliancePageUpdatesList_updates")
: null;
const [replyTo, setReplyTo] = useState(mailingList?.replyTo ?? ""); const [replyTo, setReplyTo] = useState(mailingList?.replyTo ?? "");
const [updateMailingList, isUpdating] = useMutationWithToasts<CompliancePageMailingListPage_updateMailingListMutation>( const [updateMailingList, isUpdating] = useMutationWithToasts<CompliancePageMailingListPage_updateMailingListMutation>(
@@ -85,6 +101,11 @@ export function CompliancePageMailingListPage(props: {
}); });
}; };
const handleEditUpdate = (update: UpdateNode) => {
setSelectedUpdate({ ...update });
editUpdateDialogRef.current?.open();
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{mailingListId && ( {mailingListId && (
@@ -119,27 +140,57 @@ export function CompliancePageMailingListPage(props: {
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <Tabs>
<h3 className="text-base font-medium">{__("Subscribers")}</h3> <TabItem active={activeTab === "updates"} onClick={() => setActiveTab("updates")}>
<p className="text-sm text-txt-tertiary"> {__("Updates")}
{__("People subscribed to receive security and compliance updates")} </TabItem>
</p> <TabItem active={activeTab === "subscribers"} onClick={() => setActiveTab("subscribers")}>
</div> {__("Subscribers")}
{mailingListId && ( </TabItem>
<Button icon={IconPlusLarge} onClick={() => dialogRef.current?.open()}> </Tabs>
{activeTab === "updates" && mailingListId && (
<Button icon={IconPlusLarge} onClick={() => newUpdateDialogRef.current?.open()}>
{__("Add Update")}
</Button>
)}
{activeTab === "subscribers" && mailingListId && (
<Button icon={IconPlusLarge} onClick={() => subscriberDialogRef.current?.open()}>
{__("Add Subscriber")} {__("Add Subscriber")}
</Button> </Button>
)} )}
</div> </div>
<CompliancePageMailingList fragmentRef={organization.compliancePage} /> {activeTab === "updates" && mailingList && (
<CompliancePageUpdatesList
fragmentRef={mailingList}
onEdit={handleEditUpdate}
/>
)}
{activeTab === "subscribers" && (
<CompliancePageMailingList fragmentRef={compliancePage} />
)}
</div> </div>
{mailingListId && connectionId && ( {mailingListId && updatesConnectionId && (
<NewCompliancePageSubscriberDialog <NewComplianceUpdateDialog
ref={dialogRef} ref={newUpdateDialogRef}
mailingListId={mailingListId} mailingListId={mailingListId}
connectionId={connectionId} connectionId={updatesConnectionId}
/>
)}
<EditComplianceUpdateDialog
ref={editUpdateDialogRef}
update={selectedUpdate}
/>
{mailingListId && subscriberConnectionId && (
<NewCompliancePageSubscriberDialog
ref={subscriberDialogRef}
mailingListId={mailingListId}
connectionId={subscriberConnectionId}
/> />
)} )}
</div> </div>

View File

@@ -0,0 +1,209 @@
import { useTranslate } from "@probo/i18n";
import { Badge, Button, IconChevronDown, IconPencil, IconSend, IconTrashCan, Spinner, Table, Tbody, Td, Th, Thead, Tr, useConfirm } from "@probo/ui";
import { usePaginationFragment } from "react-relay";
import { graphql } from "relay-runtime";
import type { CompliancePageUpdatesListDeleteMutation } from "#/__generated__/core/CompliancePageUpdatesListDeleteMutation.graphql";
import type { CompliancePageUpdatesListFragment$data, CompliancePageUpdatesListFragment$key } from "#/__generated__/core/CompliancePageUpdatesListFragment.graphql";
import type { CompliancePageUpdatesListQuery } from "#/__generated__/core/CompliancePageUpdatesListQuery.graphql";
import type { CompliancePageUpdatesListSendMutation } from "#/__generated__/core/CompliancePageUpdatesListSendMutation.graphql";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
const deleteMutation = graphql`
mutation CompliancePageUpdatesListDeleteMutation(
$input: DeleteMailingListUpdateInput!
$connections: [ID!]!
) {
deleteMailingListUpdate(input: $input) {
deletedMailingListUpdateId @deleteEdge(connections: $connections)
}
}
`;
const sendMutation = graphql`
mutation CompliancePageUpdatesListSendMutation($input: SendMailingListUpdateInput!) {
sendMailingListUpdate(input: $input) {
mailingListUpdate {
id
title
body
status
updatedAt
}
}
}
`;
const fragment = graphql`
fragment CompliancePageUpdatesListFragment on MailingList
@argumentDefinitions(
first: { type: Int, defaultValue: 20 }
after: { type: CursorKey, defaultValue: null }
)
@refetchable(queryName: "CompliancePageUpdatesListQuery") {
updates(
first: $first
after: $after
) @connection(key: "CompliancePageUpdatesList_updates") {
__id
pageInfo {
hasNextPage
endCursor
}
edges {
node {
id
title
body
status
createdAt
updatedAt
}
}
}
}
`;
export type UpdateNode = CompliancePageUpdatesListFragment$data["updates"]["edges"][number]["node"];
export function CompliancePageUpdatesList(props: {
fragmentRef: CompliancePageUpdatesListFragment$key;
onEdit: (update: UpdateNode) => void;
}) {
const { fragmentRef, onEdit } = props;
const { __ } = useTranslate();
const confirm = useConfirm();
const { data, hasNext, loadNext, isLoadingNext } = usePaginationFragment<
CompliancePageUpdatesListQuery,
CompliancePageUpdatesListFragment$key
>(fragment, fragmentRef);
const connection = data.updates;
const [deleteUpdate, isDeleting]
= useMutationWithToasts<CompliancePageUpdatesListDeleteMutation>(deleteMutation, {
successMessage: __("Update deleted successfully"),
errorMessage: __("Failed to delete update"),
});
const [sendUpdate, isSending]
= useMutationWithToasts<CompliancePageUpdatesListSendMutation>(sendMutation, {
successMessage: __("Update enqueued for delivery"),
errorMessage: __("Failed to enqueue update for delivery"),
});
const handleDelete = (id: string) => {
void deleteUpdate({
variables: {
input: { id },
connections: [connection.__id],
},
});
};
const handleSend = (node: UpdateNode) => {
confirm(
() =>
sendUpdate({
variables: {
input: {
id: node.id,
},
},
}),
{
title: __("Send this update to all subscribers?"),
message: `"${node.title}" — ${node.body.length > 120 ? node.body.slice(0, 120) + "…" : node.body}`,
label: __("Send"),
variant: "primary",
},
);
};
return (
<>
{connection.edges.length === 0
? (
<Table>
<Tbody>
<Tr>
<Td className="text-center text-txt-tertiary py-8">
{__("No updates yet")}
</Td>
</Tr>
</Tbody>
</Table>
)
: (
<>
<Table>
<Thead>
<Tr>
<Th>{__("Title")}</Th>
<Th>{__("Status")}</Th>
<Th>{__("Created")}</Th>
<Th />
</Tr>
</Thead>
<Tbody>
{connection.edges.map(({ node }) => (
<Tr key={node.id}>
<Td>{node.title}</Td>
<Td>
<Badge variant={node.status === "SENT" ? "success" : node.status === "DRAFT" ? "warning" : "info"}>
{node.status === "SENT" ? __("Sent") : node.status === "ENQUEUED" ? __("Queued") : node.status === "PROCESSING" ? __("Processing…") : __("Draft")}
</Badge>
</Td>
<Td className="text-txt-tertiary text-sm">
{new Date(node.createdAt).toLocaleDateString()}
</Td>
<Td className="w-auto">
<div className="flex gap-1 justify-end">
{node.status === "DRAFT" && (
<Button
icon={IconSend}
disabled={isSending}
onClick={() => handleSend(node)}
className="bg-green-600 text-white hover:bg-green-700 active:bg-green-800 shadow-none"
aria-label={__("Send")}
>
{__("Send")}
</Button>
)}
<Button
variant="tertiary"
icon={IconPencil}
onClick={() => onEdit(node)}
aria-label={__("Edit update")}
/>
<Button
variant="tertiary"
icon={IconTrashCan}
disabled={isDeleting}
onClick={() => handleDelete(node.id)}
aria-label={__("Delete update")}
/>
</div>
</Td>
</Tr>
))}
</Tbody>
</Table>
{hasNext && (
<Button
variant="tertiary"
onClick={() => loadNext(10)}
disabled={isLoadingNext}
className="mt-3 mx-auto"
icon={IconChevronDown}
>
{isLoadingNext && <Spinner />}
{__("Show More")}
</Button>
)}
</>
)}
</>
);
}

View File

@@ -0,0 +1,118 @@
import { useTranslate } from "@probo/i18n";
import { Button, Dialog, DialogContent, DialogFooter, type DialogRef, Field, IconCircleInfo, Spinner, Textarea } from "@probo/ui";
import { useEffect } from "react";
import { graphql } from "relay-runtime";
import { z } from "zod";
import type { EditComplianceUpdateDialogMutation } from "#/__generated__/core/EditComplianceUpdateDialogMutation.graphql";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
import type { UpdateNode } from "./CompliancePageUpdatesList";
const updateMutation = graphql`
mutation EditComplianceUpdateDialogMutation($input: UpdateMailingListUpdateInput!) {
updateMailingListUpdate(input: $input) {
mailingListUpdate {
id
title
body
status
updatedAt
}
}
}
`;
export function EditComplianceUpdateDialog(props: {
update: UpdateNode | null;
ref: DialogRef;
}) {
const { update, ref } = props;
const { __ } = useTranslate();
const isSent = update?.status !== "DRAFT";
const schema = z.object({
title: z.string().trim().min(1, __("Title is required")),
body: z.string().trim().min(1, __("Body is required")),
});
const form = useFormWithSchema(schema, {
defaultValues: { title: "", body: "" },
});
useEffect(() => {
if (update) {
form.reset({ title: update.title, body: update.body });
}
}, [update, form]);
const [saveUpdate, isSaving] = useMutationWithToasts<EditComplianceUpdateDialogMutation>(
updateMutation,
{
successMessage: __("Update saved successfully"),
errorMessage: __("Failed to save update"),
},
);
const handleSubmit = async (data: { title: string; body: string }) => {
if (!update) return;
await saveUpdate({
variables: {
input: {
id: update.id,
title: data.title.trim(),
body: data.body.trim(),
},
},
onCompleted: (_, errors) => {
if (!errors?.length) {
ref.current?.close();
}
},
});
};
return (
<Dialog ref={ref} title={__("Edit Update")}>
<form onSubmit={e => void form.handleSubmit(handleSubmit)(e)}>
<DialogContent padded className="space-y-6">
<div className="flex gap-2.5 rounded-lg bg-surface-secondary p-3 text-sm text-txt-secondary">
<IconCircleInfo size={16} className="mt-0.5 shrink-0 text-txt-tertiary" />
<p>
{__("Do not include confidential or sensitive information in this update. Any content that requires protection should be placed behind your NDA-gated documents instead.")}
</p>
</div>
<Field
label={__("Title")}
required
disabled={isSent}
error={form.formState.errors.title?.message}
{...form.register("title")}
/>
<Field
label={__("Body")}
required
error={form.formState.errors.body?.message}
>
<Textarea rows={6} disabled={isSent} {...form.register("body")} />
</Field>
{isSent && (
<p className="text-sm text-txt-tertiary">
{__("This update has been sent and can no longer be edited.")}
</p>
)}
</DialogContent>
<DialogFooter>
{!isSent && (
<Button type="submit" disabled={isSaving}>
{isSaving && <Spinner />}
{__("Save")}
</Button>
)}
</DialogFooter>
</form>
</Dialog>
);
}

View File

@@ -36,7 +36,7 @@ export function NewCompliancePageSubscriberDialog(props: {
const { __ } = useTranslate(); const { __ } = useTranslate();
const schema = z.object({ const schema = z.object({
fullName: z.string().min(1, __("Full name is required")).trim(), fullName: z.string().trim().min(1, __("Full name is required")),
email: z email: z
.string() .string()
.min(1, __("Email is required")) .min(1, __("Email is required"))
@@ -90,6 +90,7 @@ export function NewCompliancePageSubscriberDialog(props: {
label={__("Full Name")} label={__("Full Name")}
required required
error={form.formState.errors.fullName?.message} error={form.formState.errors.fullName?.message}
type="text"
{...form.register("fullName")} {...form.register("fullName")}
placeholder={__("John Doe")} placeholder={__("John Doe")}
/> />

View File

@@ -0,0 +1,102 @@
import { useTranslate } from "@probo/i18n";
import { Button, Dialog, DialogContent, DialogFooter, type DialogRef, Field, Spinner, Textarea } from "@probo/ui";
import { graphql } from "relay-runtime";
import { z } from "zod";
import type { NewComplianceUpdateDialogMutation } from "#/__generated__/core/NewComplianceUpdateDialogMutation.graphql";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
const createMutation = graphql`
mutation NewComplianceUpdateDialogMutation(
$input: CreateMailingListUpdateInput!
$connections: [ID!]!
) {
createMailingListUpdate(input: $input) {
mailingListUpdate
@prependNode(connections: $connections, edgeTypeName: "MailingListUpdateEdge") {
id
title
body
status
createdAt
updatedAt
}
}
}
`;
export function NewComplianceUpdateDialog(props: {
mailingListId: string;
connectionId: string;
ref: DialogRef;
onCreated?: () => void;
}) {
const { mailingListId, connectionId, ref, onCreated } = props;
const { __ } = useTranslate();
const schema = z.object({
title: z.string().trim().min(1, __("Title is required")),
body: z.string().trim().min(1, __("Body is required")),
});
const form = useFormWithSchema(schema, {
defaultValues: { title: "", body: "" },
});
const [createUpdate, isCreating] = useMutationWithToasts<NewComplianceUpdateDialogMutation>(
createMutation,
{
successMessage: __("Update created successfully"),
errorMessage: __("Failed to create update"),
},
);
const handleSubmit = async (data: { title: string; body: string }) => {
await createUpdate({
variables: {
input: {
mailingListId,
title: data.title.trim(),
body: data.body.trim(),
},
connections: [connectionId],
},
onCompleted: (_, errors) => {
if (!errors?.length) {
form.reset();
ref.current?.close();
onCreated?.();
}
},
});
};
return (
<Dialog ref={ref} title={__("Add Update")}>
<form onSubmit={e => void form.handleSubmit(handleSubmit)(e)}>
<DialogContent padded className="space-y-6">
<Field
label={__("Title")}
required
error={form.formState.errors.title?.message}
{...form.register("title")}
/>
<Field
label={__("Body")}
required
error={form.formState.errors.body?.message}
>
<Textarea rows={6} {...form.register("body")} />
</Field>
</DialogContent>
<DialogFooter>
<Button type="submit" disabled={isCreating}>
{isCreating && <Spinner />}
{__("Create")}
</Button>
</DialogFooter>
</form>
</Dialog>
);
}

View File

@@ -4,8 +4,7 @@ import { useCallback, useState, useTransition } from "react";
import { useRefetchableFragment } from "react-relay"; import { useRefetchableFragment } from "react-relay";
import { ConnectionHandler, graphql } from "relay-runtime"; import { ConnectionHandler, graphql } from "relay-runtime";
import type { CompliancePageFrameworkList_compliancePageFragment$key } from "#/__generated__/core/CompliancePageFrameworkList_compliancePageFragment.graphql"; import type { CompliancePageFrameworkList_compliancePageFragment$data, CompliancePageFrameworkList_compliancePageFragment$key } from "#/__generated__/core/CompliancePageFrameworkList_compliancePageFragment.graphql";
import type { CompliancePageFrameworkList_compliancePageFragment$data } from "#/__generated__/core/CompliancePageFrameworkList_compliancePageFragment.graphql";
import type { CompliancePageFrameworkList_compliancePageRefetchQuery } from "#/__generated__/core/CompliancePageFrameworkList_compliancePageRefetchQuery.graphql"; import type { CompliancePageFrameworkList_compliancePageRefetchQuery } from "#/__generated__/core/CompliancePageFrameworkList_compliancePageRefetchQuery.graphql";
import type { CompliancePageFrameworkList_createMutation } from "#/__generated__/core/CompliancePageFrameworkList_createMutation.graphql"; import type { CompliancePageFrameworkList_createMutation } from "#/__generated__/core/CompliancePageFrameworkList_createMutation.graphql";
import type { CompliancePageFrameworkList_deleteMutation } from "#/__generated__/core/CompliancePageFrameworkList_deleteMutation.graphql"; import type { CompliancePageFrameworkList_deleteMutation } from "#/__generated__/core/CompliancePageFrameworkList_deleteMutation.graphql";

View File

@@ -39,6 +39,8 @@ export function MainLayout(props: Props) {
<TabLink to="/documents">{__("Documents")}</TabLink> <TabLink to="/documents">{__("Documents")}</TabLink>
{trustCenter.vendorInfo.totalCount > 0 {trustCenter.vendorInfo.totalCount > 0
&& <TabLink to="/subprocessors">{__("Subprocessors")}</TabLink>} && <TabLink to="/subprocessors">{__("Subprocessors")}</TabLink>}
{isAuthenticated
&& <TabLink to="/updates">{__("Updates")}</TabLink>}
</Tabs> </Tabs>
<Outlet context={{ trustCenter }} /> <Outlet context={{ trustCenter }} />
</main> </main>

View File

@@ -0,0 +1,108 @@
import { useTranslate } from "@probo/i18n";
import { IconChevronDown } from "@probo/ui";
import { useState } from "react";
import { type PreloadedQuery, usePreloadedQuery } from "react-relay";
import { graphql } from "relay-runtime";
import type { UpdatesPageQuery } from "#/pages/__generated__/UpdatesPageQuery.graphql";
export const currentTrustUpdatesQuery = graphql`
query UpdatesPageQuery {
currentTrustCenter {
id
updates(first: 50) {
edges {
node {
id
title
body
updatedAt
}
}
}
}
}
`;
type Props = {
queryRef: PreloadedQuery<UpdatesPageQuery>;
};
export function UpdatesPage({ queryRef }: Props) {
const { __ } = useTranslate();
const data = usePreloadedQuery<UpdatesPageQuery>(
currentTrustUpdatesQuery,
queryRef,
);
const items
= data.currentTrustCenter?.updates.edges.map(e => e.node) ?? [];
return (
<div>
<h2 className="font-medium mb-1">{__("Updates")}</h2>
<p className="text-sm text-txt-secondary mb-4">
{__("Latest compliance and security updates:")}
</p>
<div className="space-y-0">
{items.map(item => (
<UpdateItem key={item.id} item={item} />
))}
</div>
</div>
);
}
type UpdateItemType = {
id: string;
title: string;
body: string;
updatedAt: string;
};
function UpdateItem({ item }: { item: UpdateItemType }) {
const [open, setOpen] = useState(false);
return (
<div className="border border-border-solid -mt-px first:rounded-t-lg last:rounded-b-lg overflow-hidden">
<button
type="button"
onClick={() => setOpen(o => !o)}
className="w-full flex items-center justify-between px-6 py-4 text-left hover:bg-highlight transition-colors group"
>
<div className="flex items-center gap-3 min-w-0">
<span className="text-sm font-medium text-txt-primary truncate">
{item.title}
</span>
</div>
<div className="flex items-center gap-4 flex-none ml-4">
<span className="text-xs text-txt-tertiary hidden sm:block">
{new Date(item.updatedAt).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
})}
</span>
<IconChevronDown
size={16}
className={`flex-none text-txt-tertiary transition-transform duration-200 ${open ? "rotate-180" : ""}`}
/>
</div>
</button>
{open && (
<div className="px-6 pb-5 pt-0 border-t border-border-solid bg-highlight/40">
<p className="text-sm text-txt-secondary whitespace-pre-wrap leading-relaxed pt-4">
{item.body}
</p>
<p className="text-xs text-txt-tertiary mt-3 sm:hidden">
{new Date(item.updatedAt).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
})}
</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,146 @@
/**
* @generated SignedSource<<4560049874109fe98528d64249673d23>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type UpdatesPageQuery$variables = Record<PropertyKey, never>;
export type UpdatesPageQuery$data = {
readonly currentTrustCenter: {
readonly id: string;
readonly updates: {
readonly edges: ReadonlyArray<{
readonly node: {
readonly body: string;
readonly id: string;
readonly title: string;
readonly updatedAt: any;
};
}>;
};
} | null | undefined;
};
export type UpdatesPageQuery = {
response: UpdatesPageQuery$data;
variables: UpdatesPageQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v1 = [
{
"alias": null,
"args": null,
"concreteType": "TrustCenter",
"kind": "LinkedField",
"name": "currentTrustCenter",
"plural": false,
"selections": [
(v0/*: any*/),
{
"alias": null,
"args": [
{
"kind": "Literal",
"name": "first",
"value": 50
}
],
"concreteType": "MailingListUpdateConnection",
"kind": "LinkedField",
"name": "updates",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "MailingListUpdateEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "MailingListUpdate",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v0/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "title",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "body",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "updatedAt",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": "updates(first:50)"
}
],
"storageKey": null
}
];
return {
"fragment": {
"argumentDefinitions": [],
"kind": "Fragment",
"metadata": null,
"name": "UpdatesPageQuery",
"selections": (v1/*: any*/),
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [],
"kind": "Operation",
"name": "UpdatesPageQuery",
"selections": (v1/*: any*/)
},
"params": {
"cacheID": "5d323cbbdf9bfec294bb22dd6a6be735",
"id": null,
"metadata": {},
"name": "UpdatesPageQuery",
"operationKind": "query",
"text": "query UpdatesPageQuery {\n currentTrustCenter {\n id\n updates(first: 50) {\n edges {\n node {\n id\n title\n body\n updatedAt\n }\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "5c0e074ce562f24bd841cc750f492988";
export default node;

View File

@@ -13,6 +13,7 @@ import { MainLayout } from "#/layouts/MainLayout";
import { DocumentsPage } from "#/pages/DocumentsPage"; import { DocumentsPage } from "#/pages/DocumentsPage";
import { OverviewPage } from "#/pages/OverviewPage"; import { OverviewPage } from "#/pages/OverviewPage";
import { SubprocessorsPage } from "#/pages/SubprocessorsPage"; import { SubprocessorsPage } from "#/pages/SubprocessorsPage";
import { currentTrustUpdatesQuery, UpdatesPage } from "#/pages/UpdatesPage";
import { import {
currentTrustDocumentsQuery, currentTrustDocumentsQuery,
currentTrustGraphQuery, currentTrustGraphQuery,
@@ -112,6 +113,25 @@ const routes = [
}, },
], ],
}, },
{
path: "/updates",
loader: loaderFromQueryLoader(() =>
loadQuery(consoleEnvironment, currentTrustGraphQuery, {}),
),
Component: withQueryRef(MainLayout),
Fallback: MainSkeleton,
ErrorBoundary: RootErrorBoundary,
children: [
{
path: "",
loader: loaderFromQueryLoader(() =>
loadQuery(consoleEnvironment, currentTrustUpdatesQuery, {}),
),
Fallback: TabSkeleton,
Component: withQueryRef(UpdatesPage),
},
],
},
// Fallback URL to the NotFound Page // Fallback URL to the NotFound Page
{ {
path: "*", path: "*",

View File

@@ -219,6 +219,9 @@ const (
subjectTrustCenterDocumentAccessRejected = "Compliance Page Document Access Rejected - %s" subjectTrustCenterDocumentAccessRejected = "Compliance Page Document Access Rejected - %s"
subjectMagicLink = "Connect to %s" subjectMagicLink = "Connect to %s"
subjectElectronicSignatureCertificate = "Your signed %s - Certificate of Completion" subjectElectronicSignatureCertificate = "Your signed %s - Certificate of Completion"
subjectMailingListSubscription = "%s – Confirm Your Compliance Updates Subscription"
subjectMailingListUnsubscription = "%s – You've been unsubscribed"
subjectMailingListUpdates = "%s – %s"
) )
var ( var (
@@ -242,6 +245,12 @@ var (
magicLinkTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/magic-link.txt.tmpl")) magicLinkTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/magic-link.txt.tmpl"))
electronicSignatureCertificateHTMLTemplate = htmltemplate.Must(htmltemplate.ParseFS(Templates, "dist/electronic-signature-certificate.html.tmpl")) electronicSignatureCertificateHTMLTemplate = htmltemplate.Must(htmltemplate.ParseFS(Templates, "dist/electronic-signature-certificate.html.tmpl"))
electronicSignatureCertificateTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/electronic-signature-certificate.txt.tmpl")) electronicSignatureCertificateTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/electronic-signature-certificate.txt.tmpl"))
mailingListSubscriptionHTMLTemplate = htmltemplate.Must(htmltemplate.ParseFS(Templates, "dist/mailing-list-subscription.html.tmpl"))
mailingListSubscriptionTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/mailing-list-subscription.txt.tmpl"))
mailingListUnsubscriptionHTMLTemplate = htmltemplate.Must(htmltemplate.ParseFS(Templates, "dist/mailing-list-unsubscription.html.tmpl"))
mailingListUnsubscriptionTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/mailing-list-unsubscription.txt.tmpl"))
mailingListUpdatesHTMLTemplate = htmltemplate.Must(htmltemplate.ParseFS(Templates, "dist/mailing-list-updates.html.tmpl"))
mailingListUpdatesTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/mailing-list-updates.txt.tmpl"))
) )
func (p *Presenter) getCommonVariables(ctx context.Context) (*CommonVariables, error) { func (p *Presenter) getCommonVariables(ctx context.Context) (*CommonVariables, error) {
@@ -489,6 +498,84 @@ func (p *Presenter) RenderElectronicSignatureCertificate(ctx context.Context, si
return fmt.Sprintf(subjectElectronicSignatureCertificate, documentType), textBody, htmlBody, err return fmt.Sprintf(subjectElectronicSignatureCertificate, documentType), textBody, htmlBody, err
} }
func (p *Presenter) RenderMailingListSubscription(ctx context.Context, organizationName string, confirmURL string, unsubscribeURL string) (subject string, textBody string, htmlBody *string, err error) {
vars, err := p.getCommonVariables(ctx)
if err != nil {
return "", "", nil, fmt.Errorf("cannot get common variables: %w", err)
}
data := struct {
*CommonVariables
OrganizationName string
ConfirmURL string
UnsubscribeURL string
}{
CommonVariables: vars,
OrganizationName: organizationName,
ConfirmURL: confirmURL,
UnsubscribeURL: unsubscribeURL,
}
textBody, htmlBody, err = renderEmail(mailingListSubscriptionTextTemplate, mailingListSubscriptionHTMLTemplate, data)
if err != nil {
return "", "", nil, err
}
return fmt.Sprintf(subjectMailingListSubscription, organizationName), textBody, htmlBody, nil
}
func (p *Presenter) RenderMailingListUnsubscription(ctx context.Context, organizationName string) (subject string, textBody string, htmlBody *string, err error) {
vars, err := p.getCommonVariables(ctx)
if err != nil {
return "", "", nil, fmt.Errorf("cannot get common variables: %w", err)
}
data := struct {
*CommonVariables
OrganizationName string
}{
CommonVariables: vars,
OrganizationName: organizationName,
}
textBody, htmlBody, err = renderEmail(mailingListUnsubscriptionTextTemplate, mailingListUnsubscriptionHTMLTemplate, data)
if err != nil {
return "", "", nil, err
}
return fmt.Sprintf(subjectMailingListUnsubscription, organizationName), textBody, htmlBody, nil
}
func (p *Presenter) RenderMailingListNews(ctx context.Context, organizationName string, newsTitle string, newsBody string, compliancePageURL string, unsubscribeURL string) (subject string, textBody string, htmlBody *string, err error) {
vars, err := p.getCommonVariables(ctx)
if err != nil {
return "", "", nil, fmt.Errorf("cannot get common variables: %w", err)
}
data := struct {
*CommonVariables
OrganizationName string
NewsTitle string
NewsBody string
CompliancePageURL string
UnsubscribeURL string
}{
CommonVariables: vars,
OrganizationName: organizationName,
NewsTitle: newsTitle,
NewsBody: newsBody,
CompliancePageURL: compliancePageURL,
UnsubscribeURL: unsubscribeURL,
}
textBody, htmlBody, err = renderEmail(mailingListUpdatesTextTemplate, mailingListUpdatesHTMLTemplate, data)
if err != nil {
return "", "", nil, err
}
return fmt.Sprintf(subjectMailingListUpdates, organizationName, newsTitle), textBody, htmlBody, nil
}
func renderEmail(textTemplate *texttemplate.Template, htmlTemplate *htmltemplate.Template, data any) (textBody string, htmlBody *string, err error) { func renderEmail(textTemplate *texttemplate.Template, htmlTemplate *htmltemplate.Template, data any) (textBody string, htmlBody *string, err error) {
var textBuf bytes.Buffer var textBuf bytes.Buffer
if err := textTemplate.Execute(&textBuf, data); err != nil { if err := textTemplate.Execute(&textBuf, data); err != nil {

View File

@@ -4,6 +4,7 @@ import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import * as React from "react"; import * as React from "react";
import MailingListUpdates from "../src/MailingListUpdates";
import ConfirmEmail from "../src/ConfirmEmail"; import ConfirmEmail from "../src/ConfirmEmail";
import DocumentExport from "../src/DocumentExport"; import DocumentExport from "../src/DocumentExport";
import DocumentSigning from "../src/DocumentSigning"; import DocumentSigning from "../src/DocumentSigning";
@@ -13,6 +14,8 @@ import PasswordReset from "../src/PasswordReset";
import TrustCenterAccess from "../src/TrustCenterAccess"; import TrustCenterAccess from "../src/TrustCenterAccess";
import TrustCenterDocumentAccessRejected from "../src/TrustCenterDocumentAccessRejected"; import TrustCenterDocumentAccessRejected from "../src/TrustCenterDocumentAccessRejected";
import ElectronicSignatureCertificate from "../src/ElectronicSignatureCertificate"; import ElectronicSignatureCertificate from "../src/ElectronicSignatureCertificate";
import MailingListSubscription from "../src/MailingListSubscription";
import MailingListUnsubscription from "../src/MailingListUnsubscription";
import MagicLink from "../src/MagicLink"; import MagicLink from "../src/MagicLink";
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
@@ -64,6 +67,18 @@ const templates: TemplateConfig[] = [
name: "electronic-signature-certificate", name: "electronic-signature-certificate",
render: () => ElectronicSignatureCertificate(), render: () => ElectronicSignatureCertificate(),
}, },
{
name: "mailing-list-subscription",
render: () => MailingListSubscription(),
},
{
name: "mailing-list-unsubscription",
render: () => MailingListUnsubscription(),
},
{
name: "mailing-list-updates",
render: () => MailingListUpdates(),
},
]; ];
async function build() { async function build() {

View File

@@ -0,0 +1,46 @@
import { Button, Hr, Link, Section, Text } from "@react-email/components";
import * as React from "react";
import EmailLayout, {
bodyText,
button,
buttonContainer,
footerText,
} from "./components/EmailLayout";
export const MailingListSubscription = () => {
return (
<EmailLayout
subject={`${"{{.OrganizationName}}"} – Confirm Your Compliance Updates Subscription`}
>
<Text style={bodyText}>
You requested to subscribe to compliance updates from{" "}
<strong>{"{{.OrganizationName}}"}</strong>.
</Text>
<Text style={bodyText}>
Please click the button below to confirm your subscription.
</Text>
<Section style={buttonContainer}>
<Button style={button} href={"{{.ConfirmURL}}"}>
Confirm Subscription
</Button>
</Section>
<Hr style={{ borderColor: "#ecefec", margin: "8px 0 20px" }} />
<Text style={{ ...footerText, textAlign: "center" }}>
If you did not request this subscription, or no longer want to receive
these emails,{" "}
<Link
href={"{{.UnsubscribeURL}}"}
style={{ color: "#374151", textDecoration: "underline", fontWeight: "500" }}
>
unsubscribe here.
</Link>
</Text>
</EmailLayout>
);
};
export default MailingListSubscription;

View File

@@ -0,0 +1,26 @@
import { Hr, Text } from "@react-email/components";
import * as React from "react";
import EmailLayout, { bodyText, footerText } from "./components/EmailLayout";
export const MailingListUnsubscription = () => {
return (
<EmailLayout
subject={`${"{{.OrganizationName}}"} – You've been unsubscribed`}
>
<Text style={bodyText}>
You've been successfully unsubscribed from{" "}
<strong>{"{{.OrganizationName}}"}</strong>'s compliance updates. You
will no longer receive notifications when new information is published
on their compliance page.
</Text>
<Hr style={{ borderColor: "#ecefec", margin: "8px 0 20px" }} />
<Text style={{ ...footerText, textAlign: "center" }}>
This email was sent to confirm your unsubscription request.
</Text>
</EmailLayout>
);
};
export default MailingListUnsubscription;

View File

@@ -0,0 +1,48 @@
import { Button, Hr, Link, Section, Text } from "@react-email/components";
import * as React from "react";
import EmailLayout, {
bodyText,
button,
buttonContainer,
footerText,
} from "./components/EmailLayout";
export const MailingListUpdates = () => {
return (
<EmailLayout
subject={`${"{{.OrganizationName}}"} – ${"{{.NewsTitle}}"}`}
>
<Text style={bodyText}>
{"{{.OrganizationName}}"} has published a new compliance update.
</Text>
<Text style={{ ...bodyText, fontWeight: "600", fontSize: "18px" }}>
{"{{.NewsTitle}}"}
</Text>
<Text style={bodyText}>{"{{.NewsBody}}"}</Text>
<Section style={buttonContainer}>
<Button style={button} href={"{{.CompliancePageURL}}"}>
View Compliance Page
</Button>
</Section>
<Hr style={{ borderColor: "#ecefec", margin: "8px 0 20px" }} />
<Text style={{ ...footerText, textAlign: "center" }}>
You are receiving this email because you subscribed to compliance
updates from <strong>{"{{.OrganizationName}}"}</strong>. To
unsubscribe,{" "}
<Link
href={"{{.UnsubscribeURL}}"}
style={{ color: "#374151", textDecoration: "underline", fontWeight: "500" }}
>
click here.
</Link>
</Text>
</EmailLayout>
);
};
export default MailingListUpdates;

View File

@@ -0,0 +1,16 @@
{{.SenderCompanyName}}
Hi {{.RecipientFullName}},
You requested to subscribe to compliance updates from {{.OrganizationName}}.
Please click the link below to confirm your subscription:
{{.ConfirmURL}}
If you did not request this subscription, or no longer want to receive these emails, unsubscribe here:
{{.UnsubscribeURL}}
{{.SenderCompanyHeadquarterAddress}}
Powered By Probo

View File

@@ -0,0 +1,8 @@
{{.SenderCompanyName}}
Hi {{.RecipientFullName}},
You've been successfully unsubscribed from {{.OrganizationName}}'s compliance updates. You will no longer receive notifications when new information is published on their compliance page.
{{.SenderCompanyHeadquarterAddress}}
Powered By Probo

View File

@@ -0,0 +1,22 @@
{{.SenderCompanyName}}
Hi {{.RecipientFullName}},
{{.OrganizationName}} has published a new compliance update.
{{.NewsTitle}}
{{.NewsBody}}
View the compliance page:
{{.CompliancePageURL}}
You are receiving this email because you subscribed to compliance updates from {{.OrganizationName}}.
To unsubscribe, visit:
{{.UnsubscribeURL}}
{{.SenderCompanyHeadquarterAddress}}
Powered By Probo

View File

@@ -31,6 +31,9 @@ type (
ID gid.GID `db:"id"` ID gid.GID `db:"id"`
RecipientEmail string `db:"recipient_email"` RecipientEmail string `db:"recipient_email"`
RecipientName string `db:"recipient_name"` 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"` Subject string `db:"subject"`
TextBody string `db:"text_body"` TextBody string `db:"text_body"`
HtmlBody *string `db:"html_body"` HtmlBody *string `db:"html_body"`
@@ -44,6 +47,14 @@ type (
UpdatedAt time.Time `db:"updated_at"` UpdatedAt time.Time `db:"updated_at"`
SentAt *time.Time `db:"sent_at"` SentAt *time.Time `db:"sent_at"`
} }
Emails []*Email
EmailOptions struct {
ReplyTo *mail.Addr
UnsubscribeURL *string
MailingListUpdateID *gid.GID
}
) )
var ( var (
@@ -62,9 +73,10 @@ func NewEmail(
subject string, subject string,
textBody string, textBody string,
htmlBody *string, htmlBody *string,
opts *EmailOptions,
) *Email { ) *Email {
now := time.Now() now := time.Now()
return &Email{ e := &Email{
ID: gid.New(gid.NilTenant, EmailEntityType), ID: gid.New(gid.NilTenant, EmailEntityType),
RecipientName: recipientName, RecipientName: recipientName,
RecipientEmail: recipientEmail.String(), RecipientEmail: recipientEmail.String(),
@@ -77,6 +89,14 @@ func NewEmail(
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
} }
if opts != nil {
e.ReplyTo = opts.ReplyTo
e.UnsubscribeURL = opts.UnsubscribeURL
e.MailingListUpdateID = opts.MailingListUpdateID
}
return e
} }
func (e *Email) Insert( func (e *Email) Insert(
@@ -85,12 +105,35 @@ func (e *Email) Insert(
) error { ) error {
q := ` q := `
INSERT INTO emails ( INSERT INTO emails (
id, recipient_email, recipient_name, subject, text_body, html_body, id,
status, attempt_count, max_attempts, created_at, updated_at 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 ( VALUES (
@id, @recipient_email, @recipient_name, @subject, @text_body, @html_body, @id,
@status, @attempt_count, @max_attempts, @created_at, @updated_at @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
) )
` `
@@ -98,6 +141,9 @@ VALUES (
"id": e.ID, "id": e.ID,
"recipient_email": e.RecipientEmail, "recipient_email": e.RecipientEmail,
"recipient_name": e.RecipientName, "recipient_name": e.RecipientName,
"reply_to": e.ReplyTo,
"unsubscribe_url": e.UnsubscribeURL,
"mailing_list_update_id": e.MailingListUpdateID,
"subject": e.Subject, "subject": e.Subject,
"text_body": e.TextBody, "text_body": e.TextBody,
"html_body": e.HtmlBody, "html_body": e.HtmlBody,
@@ -112,13 +158,47 @@ VALUES (
return err 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( func (e *Email) LoadNextPendingForUpdateSkipLocked(
ctx context.Context, ctx context.Context,
conn pg.Conn, conn pg.Conn,
) error { ) error {
q := ` q := `
SELECT 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, status, processing_started_at, attempt_count, max_attempts,
last_attempted_at, last_error, created_at, updated_at, sent_at last_attempted_at, last_error, created_at, updated_at, sent_at
FROM emails FROM emails

View File

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

View File

@@ -67,6 +67,56 @@ func (cns *MailingListSubscriber) CursorKey(orderBy MailingListSubscriberOrderFi
panic(fmt.Sprintf("unsupported order by: %s", orderBy)) 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( func (cns *MailingListSubscriber) LoadByMailingListIDAndEmail(
ctx context.Context, ctx context.Context,
conn pg.Conn, conn pg.Conn,
@@ -227,11 +277,15 @@ WHERE
args := pgx.StrictNamedArgs{"mailing_list_subscriber_id": cns.ID} args := pgx.StrictNamedArgs{"mailing_list_subscriber_id": cns.ID}
maps.Copy(args, scope.SQLArguments()) maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, q, args) tag, err := conn.Exec(ctx, q, args)
if err != nil { if err != nil {
return fmt.Errorf("cannot delete mailing list subscriber: %w", err) return fmt.Errorf("cannot delete mailing list subscriber: %w", err)
} }
if tag.RowsAffected() == 0 {
return ErrResourceNotFound
}
return nil return nil
} }
@@ -267,6 +321,50 @@ WHERE
return count, nil 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( func (cnss *MailingListSubscribers) LoadByMailingListID(
ctx context.Context, ctx context.Context,
conn pg.Conn, 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 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( func (tc *TrustCenter) LoadByOrganizationID(
ctx context.Context, ctx context.Context,
conn pg.Conn, conn pg.Conn,

View File

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

View File

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

View File

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

View File

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

View File

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

@@ -20,4 +20,6 @@ var (
ErrMailingListNotFound = errors.New("mailing list not found") ErrMailingListNotFound = errors.New("mailing list not found")
ErrSubscriberNotFound = errors.New("mailing list subscriber not found") ErrSubscriberNotFound = errors.New("mailing list subscriber not found")
ErrSubscriberAlreadyExist = errors.New("mailing list subscriber already exists") 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" "fmt"
"time" "time"
"go.gearno.de/kit/log"
"go.gearno.de/kit/pg" "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/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/gid"
"go.probo.inc/probo/pkg/mail" "go.probo.inc/probo/pkg/mail"
"go.probo.inc/probo/pkg/page" "go.probo.inc/probo/pkg/page"
) )
const (
pathUnsubscribe = "/mail-actions/unsubscribe"
pathConfirm = "/mail-actions/confirm"
)
type Service struct { 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 { 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} return &Service{pg: pgClient, fm: fm, tokenSecret: tokenSecret, apiBaseURL: apiBaseURL, bucket: bucket, encryptionKey: encryptionKey, logger: logger}
} }
func (s *Service) UpdateMailingList( func (s *Service) UpdateMailingList(
@@ -40,8 +56,8 @@ func (s *Service) UpdateMailingList(
id gid.GID, id gid.GID,
replyTo *mail.Addr, replyTo *mail.Addr,
) (*coredata.MailingList, error) { ) (*coredata.MailingList, error) {
var ml coredata.MailingList
scope := coredata.NewScopeFromObjectID(id) scope := coredata.NewScopeFromObjectID(id)
ml := coredata.MailingList{}
err := s.pg.WithConn( err := s.pg.WithConn(
ctx, ctx,
@@ -105,8 +121,12 @@ func (s *Service) CreateSubscriber(
fullName string, fullName string,
) (*coredata.MailingListSubscriber, error) { ) (*coredata.MailingListSubscriber, error) {
scope := coredata.NewScopeFromObjectID(mailingListID) 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{ subscriber := &coredata.MailingListSubscriber{
ID: gid.New(scope.GetTenantID(), coredata.MailingListSubscriberEntityType), ID: gid.New(scope.GetTenantID(), coredata.MailingListSubscriberEntityType),
MailingListID: mailingListID, MailingListID: mailingListID,
@@ -117,53 +137,150 @@ func (s *Service) CreateSubscriber(
UpdatedAt: now, UpdatedAt: now,
} }
err := s.pg.WithConn( if err := s.pg.WithTx(
ctx, ctx,
func(conn pg.Conn) error { func(tx pg.Conn) error {
ml := coredata.MailingList{} var ml coredata.MailingList
if err := ml.LoadByID(ctx, conn, scope, mailingListID); err != nil { if err := ml.LoadByID(ctx, tx, scope, mailingListID); err != nil {
return fmt.Errorf("cannot load mailing list: %w", err) return fmt.Errorf("cannot load mailing list: %w", err)
} }
subscriber.OrganizationID = ml.OrganizationID 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) { if errors.Is(err, coredata.ErrResourceAlreadyExists) {
return ErrSubscriberAlreadyExist return ErrSubscriberAlreadyExist
} }
return fmt.Errorf("cannot insert mailing list subscriber: %w", err) 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 return nil
}, },
) ); err != nil {
if err != nil {
return nil, err return nil, err
} }
return subscriber, nil 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( func (s *Service) DeleteSubscriber(
ctx context.Context, ctx context.Context,
id gid.GID, id gid.GID,
) error { ) error {
scope := coredata.NewScopeFromObjectID(id) scope := coredata.NewScopeFromObjectID(id)
err := s.pg.WithConn( return s.pg.WithTx(
ctx, ctx,
func(conn pg.Conn) error { func(tx pg.Conn) error {
subscriber := coredata.MailingListSubscriber{ID: id} var subscriber coredata.MailingListSubscriber
if err := subscriber.Delete(ctx, conn, scope); err != nil { 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) return fmt.Errorf("cannot delete mailing list subscriber: %w", err)
} }
return nil
}, if wasConfirmed {
) emailRecord, err := s.buildUnsubscriptionMail(ctx, subscriber.MailingListID, subscriber.Email, subscriber.FullName)
if err != nil { if err != nil {
return err 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 return nil
},
)
} }
func (s *Service) CountSubscribers( func (s *Service) CountSubscribers(
@@ -171,7 +288,7 @@ func (s *Service) CountSubscribers(
mailingListID gid.GID, mailingListID gid.GID,
) (int, error) { ) (int, error) {
scope := coredata.NewScopeFromObjectID(mailingListID) scope := coredata.NewScopeFromObjectID(mailingListID)
count := 0 var count int
err := s.pg.WithConn( err := s.pg.WithConn(
ctx, ctx,
@@ -197,7 +314,7 @@ func (s *Service) ListSubscribers(
cursor *page.Cursor[coredata.MailingListSubscriberOrderField], cursor *page.Cursor[coredata.MailingListSubscriberOrderField],
) (*page.Page[*coredata.MailingListSubscriber, coredata.MailingListSubscriberOrderField], error) { ) (*page.Page[*coredata.MailingListSubscriber, coredata.MailingListSubscriberOrderField], error) {
scope := coredata.NewScopeFromObjectID(mailingListID) scope := coredata.NewScopeFromObjectID(mailingListID)
subscribers := coredata.MailingListSubscribers{} var subscribers coredata.MailingListSubscribers
err := s.pg.WithConn( err := s.pg.WithConn(
ctx, ctx,
@@ -214,3 +331,386 @@ func (s *Service) ListSubscribers(
return page.NewPage(subscribers, cursor), nil 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" ActionTrustCenterAccessUpdate = "core:trust-center-access:update"
ActionTrustCenterAccessDelete = "core:trust-center-access:delete" 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 // MailingList actions
ActionMailingListUpdate = "core:mailing-list:update" ActionMailingListUpdate = "core:mailing-list:update"

View File

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

View File

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

View File

@@ -436,6 +436,8 @@ func (impl *Implm) Run(
l.Named("esign"), l.Named("esign"),
) )
mailmanService := mailman.NewService(pgClient, fileManagerService, impl.cfg.Auth.Cookie.Secret, baseURL, impl.cfg.AWS.Bucket, encryptionKey, l)
proboService, err := probo.NewService( proboService, err := probo.NewService(
ctx, ctx,
encryptionKey, encryptionKey,
@@ -472,8 +474,6 @@ func (impl *Implm) Run(
slackService, slackService,
) )
mailmanService := mailman.NewService(pgClient)
serverHandler, err := server.NewServer( serverHandler, err := server.NewServer(
server.Config{ server.Config{
AllowedOrigins: impl.cfg.Api.Cors.AllowedOrigins, 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()) trustCenterServerCtx, stopTrustCenterServer := context.WithCancel(context.Background())
defer stopTrustCenterServer() defer stopTrustCenterServer()
wg.Go( wg.Go(
@@ -619,6 +629,7 @@ func (impl *Implm) Run(
stopTrustCenterServer() stopTrustCenterServer()
stopWebhookSender() stopWebhookSender()
stopESignService() stopESignService()
stopMailingListWorker()
stopExportJobExporter() stopExportJobExporter()
stopIAMService() stopIAMService()
stopMailer() stopMailer()

View File

@@ -1695,6 +1695,13 @@ type MailingList implements Node {
last: Int last: Int
before: CursorKey before: CursorKey
): MailingListSubscriberConnection! @goField(forceResolver: true) ): MailingListSubscriberConnection! @goField(forceResolver: true)
updates(
first: Int
after: CursorKey
last: Int
before: CursorKey
): MailingListUpdateConnection! @goField(forceResolver: true)
} }
enum MailingListSubscriberStatus enum MailingListSubscriberStatus
@@ -1734,6 +1741,51 @@ type MailingListSubscriberEdge {
node: MailingListSubscriber! 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 { type Organization implements Node {
id: ID! id: ID!
name: String! name: String!
@@ -3407,6 +3459,19 @@ type Mutation {
deleteTrustCenterAccess( deleteTrustCenterAccess(
input: DeleteTrustCenterAccessInput! input: DeleteTrustCenterAccessInput!
): DeleteTrustCenterAccessPayload! ): DeleteTrustCenterAccessPayload!
# Compliance News mutations
createMailingListUpdate(
input: CreateMailingListUpdateInput!
): CreateMailingListUpdatePayload!
updateMailingListUpdate(
input: UpdateMailingListUpdateInput!
): UpdateMailingListUpdatePayload!
sendMailingListUpdate(
input: SendMailingListUpdateInput!
): SendMailingListUpdatePayload!
deleteMailingListUpdate(
input: DeleteMailingListUpdateInput!
): DeleteMailingListUpdatePayload!
# Mailing List mutations # Mailing List mutations
updateMailingList( updateMailingList(
input: UpdateMailingListInput! input: UpdateMailingListInput!
@@ -3829,6 +3894,26 @@ type UpdateMailingListPayload {
mailingList: MailingList! 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 { input CreateMailingListSubscriberInput {
mailingListId: ID! mailingListId: ID!
fullName: String! fullName: String!
@@ -4690,6 +4775,22 @@ type DeleteTrustCenterAccessPayload {
deletedTrustCenterAccessId: ID! deletedTrustCenterAccessId: ID!
} }
type CreateMailingListUpdatePayload {
mailingListUpdate: MailingListUpdate!
}
type UpdateMailingListUpdatePayload {
mailingListUpdate: MailingListUpdate!
}
type SendMailingListUpdatePayload {
mailingListUpdate: MailingListUpdate!
}
type DeleteMailingListUpdatePayload {
deletedMailingListUpdateId: ID!
}
type CreateMailingListSubscriberPayload { type CreateMailingListSubscriberPayload {
mailingListSubscriberEdge: MailingListSubscriberEdge! 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"` 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 { type CreateMeasureInput struct {
OrganizationID gid.GID `json:"organizationId"` OrganizationID gid.GID `json:"organizationId"`
Name string `json:"name"` Name string `json:"name"`
@@ -977,6 +987,14 @@ type DeleteMailingListSubscriberPayload struct {
DeletedMailingListSubscriberID gid.GID `json:"deletedMailingListSubscriberId"` DeletedMailingListSubscriberID gid.GID `json:"deletedMailingListSubscriberId"`
} }
type DeleteMailingListUpdateInput struct {
ID gid.GID `json:"id"`
}
type DeleteMailingListUpdatePayload struct {
DeletedMailingListUpdateID gid.GID `json:"deletedMailingListUpdateId"`
}
type DeleteMeasureInput struct { type DeleteMeasureInput struct {
MeasureID gid.GID `json:"measureId"` MeasureID gid.GID `json:"measureId"`
} }
@@ -1453,6 +1471,7 @@ type MailingList struct {
ID gid.GID `json:"id"` ID gid.GID `json:"id"`
ReplyTo *mail.Addr `json:"replyTo,omitempty"` ReplyTo *mail.Addr `json:"replyTo,omitempty"`
Subscribers *MailingListSubscriberConnection `json:"subscribers"` Subscribers *MailingListSubscriberConnection `json:"subscribers"`
Updates *MailingListUpdateConnection `json:"updates"`
} }
func (MailingList) IsNode() {} func (MailingList) IsNode() {}
@@ -1475,6 +1494,23 @@ type MailingListSubscriberEdge struct {
Node *MailingListSubscriber `json:"node"` 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 { type Measure struct {
ID gid.GID `json:"id"` ID gid.GID `json:"id"`
Category string `json:"category"` Category string `json:"category"`
@@ -1815,6 +1851,14 @@ type RiskFilter struct {
SnapshotID *gid.GID `json:"snapshotId,omitempty"` SnapshotID *gid.GID `json:"snapshotId,omitempty"`
} }
type SendMailingListUpdateInput struct {
ID gid.GID `json:"id"`
}
type SendMailingListUpdatePayload struct {
MailingListUpdate *MailingListUpdate `json:"mailingListUpdate"`
}
type SendSigningNotificationsInput struct { type SendSigningNotificationsInput struct {
OrganizationID gid.GID `json:"organizationId"` OrganizationID gid.GID `json:"organizationId"`
} }
@@ -2181,6 +2225,16 @@ type UpdateMailingListPayload struct {
MailingList *MailingList `json:"mailingList"` 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 { type UpdateMeasureInput struct {
ID gid.GID `json:"id"` ID gid.GID `json:"id"`
Name *string `json:"name,omitempty"` Name *string `json:"name,omitempty"`

View File

@@ -18,6 +18,7 @@ import (
"go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/iam" "go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/mailman"
"go.probo.inc/probo/pkg/page" "go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/probo" "go.probo.inc/probo/pkg/probo"
"go.probo.inc/probo/pkg/server/api/authn" "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 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. // TotalCount is the resolver for the totalCount field.
func (r *mailingListSubscriberConnectionResolver) TotalCount(ctx context.Context, obj *types.MailingListSubscriberConnection) (int, error) { func (r *mailingListSubscriberConnectionResolver) TotalCount(ctx context.Context, obj *types.MailingListSubscriberConnection) (int, error) {
if err := r.authorize(ctx, obj.ParentID, probo.ActionMailingListSubscriberList); err != nil { 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)) 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. // 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) { 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 { 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 }, 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. // UpdateMailingList is the resolver for the updateMailingList field.
func (r *mutationResolver) UpdateMailingList(ctx context.Context, input types.UpdateMailingListInput) (*types.UpdateMailingListPayload, error) { func (r *mutationResolver) UpdateMailingList(ctx context.Context, input types.UpdateMailingListInput) (*types.UpdateMailingListPayload, error) {
if err := r.authorize(ctx, input.ID, probo.ActionMailingListUpdate); err != nil { 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 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 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)) r.logger.ErrorCtx(ctx, "cannot create mailing list subscriber", log.Error(err))
return nil, gqlutils.Internal(ctx) 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 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)) r.logger.ErrorCtx(ctx, "cannot delete mailing list subscriber", log.Error(err))
return nil, gqlutils.Internal(ctx) return nil, gqlutils.Internal(ctx)
} }
@@ -9196,6 +9327,11 @@ func (r *Resolver) MailingListSubscriberConnection() schema.MailingListSubscribe
return &mailingListSubscriberConnectionResolver{r} return &mailingListSubscriberConnectionResolver{r}
} }
// MailingListUpdateConnection returns schema.MailingListUpdateConnectionResolver implementation.
func (r *Resolver) MailingListUpdateConnection() schema.MailingListUpdateConnectionResolver {
return &mailingListUpdateConnectionResolver{r}
}
// Measure returns schema.MeasureResolver implementation. // Measure returns schema.MeasureResolver implementation.
func (r *Resolver) Measure() schema.MeasureResolver { return &measureResolver{r} } func (r *Resolver) Measure() schema.MeasureResolver { return &measureResolver{r} }
@@ -9432,6 +9568,7 @@ type frameworkResolver struct{ *Resolver }
type frameworkConnectionResolver struct{ *Resolver } type frameworkConnectionResolver struct{ *Resolver }
type mailingListResolver struct{ *Resolver } type mailingListResolver struct{ *Resolver }
type mailingListSubscriberConnectionResolver struct{ *Resolver } type mailingListSubscriberConnectionResolver struct{ *Resolver }
type mailingListUpdateConnectionResolver struct{ *Resolver }
type measureResolver struct{ *Resolver } type measureResolver struct{ *Resolver }
type measureConnectionResolver struct{ *Resolver } type measureConnectionResolver struct{ *Resolver }
type meetingResolver struct{ *Resolver } type meetingResolver struct{ *Resolver }

View File

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

View File

@@ -131,6 +131,23 @@ type ComplianceFrameworkEdge
node: ComplianceFramework! 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 enum CountryCode
@goModel(model: "go.probo.inc/probo/pkg/coredata.CountryCode") { @goModel(model: "go.probo.inc/probo/pkg/coredata.CountryCode") {
AD @goEnum(value: "go.probo.inc/probo/pkg/coredata.CountryCodeAD") AD @goEnum(value: "go.probo.inc/probo/pkg/coredata.CountryCodeAD")
@@ -581,6 +598,13 @@ type TrustCenter implements Node {
last: Int last: Int
before: CursorKey before: CursorKey
): ComplianceExternalURLConnection! @goField(forceResolver: true) ): ComplianceExternalURLConnection! @goField(forceResolver: true)
updates(
first: Int
after: CursorKey
last: Int
before: CursorKey
): MailingListUpdateConnection! @goField(forceResolver: true)
} }
type ComplianceExternalURL implements Node { type ComplianceExternalURL implements Node {
@@ -926,6 +950,7 @@ type MailingListSubscriber implements Node
model: "go.probo.inc/probo/pkg/server/api/trust/v1/types.MailingListSubscriber" model: "go.probo.inc/probo/pkg/server/api/trust/v1/types.MailingListSubscriber"
) { ) {
id: ID! id: ID!
fullName: String!
email: EmailAddr! email: EmailAddr!
status: MailingListSubscriberStatus! status: MailingListSubscriberStatus!
createdAt: Datetime! createdAt: Datetime!

View File

@@ -175,11 +175,29 @@ type ComplexityRoot struct {
MailingListSubscriber struct { MailingListSubscriber struct {
CreatedAt func(childComplexity int) int CreatedAt func(childComplexity int) int
Email func(childComplexity int) int Email func(childComplexity int) int
FullName func(childComplexity int) int
ID func(childComplexity int) int ID func(childComplexity int) int
Status func(childComplexity int) int Status func(childComplexity int) int
UpdatedAt 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 { Mutation struct {
AcceptElectronicSignature func(childComplexity int, input types.AcceptElectronicSignatureInput) int AcceptElectronicSignature func(childComplexity int, input types.AcceptElectronicSignatureInput) int
ExportDocumentPDF func(childComplexity int, input types.ExportDocumentPDFInput) 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 References func(childComplexity int, first *int, after *page.CursorKey, last *int, before *page.CursorKey) int
Slug func(childComplexity int) int Slug func(childComplexity int) int
TrustCenterFiles func(childComplexity int, first *int, after *page.CursorKey, last *int, before *page.CursorKey) 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 Vendors func(childComplexity int, first *int, after *page.CursorKey, last *int, before *page.CursorKey) int
ViewerSubscription func(childComplexity int) 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) 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) 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) 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 { type TrustCenterFileResolver interface {
IsUserAuthorized(ctx context.Context, obj *types.TrustCenterFile) (bool, error) 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 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": case "MailingListSubscriber.id":
if e.ComplexityRoot.MailingListSubscriber.ID == nil { if e.ComplexityRoot.MailingListSubscriber.ID == nil {
break break
@@ -824,6 +850,57 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
return e.ComplexityRoot.MailingListSubscriber.UpdatedAt(childComplexity), true 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": case "Mutation.acceptElectronicSignature":
if e.ComplexityRoot.Mutation.AcceptElectronicSignature == nil { if e.ComplexityRoot.Mutation.AcceptElectronicSignature == nil {
break 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 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": case "TrustCenter.vendors":
if e.ComplexityRoot.TrustCenter.Vendors == nil { if e.ComplexityRoot.TrustCenter.Vendors == nil {
break break
@@ -1744,6 +1832,23 @@ type ComplianceFrameworkEdge
node: ComplianceFramework! 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 enum CountryCode
@goModel(model: "go.probo.inc/probo/pkg/coredata.CountryCode") { @goModel(model: "go.probo.inc/probo/pkg/coredata.CountryCode") {
AD @goEnum(value: "go.probo.inc/probo/pkg/coredata.CountryCodeAD") AD @goEnum(value: "go.probo.inc/probo/pkg/coredata.CountryCodeAD")
@@ -2194,6 +2299,13 @@ type TrustCenter implements Node {
last: Int last: Int
before: CursorKey before: CursorKey
): ComplianceExternalURLConnection! @goField(forceResolver: true) ): ComplianceExternalURLConnection! @goField(forceResolver: true)
updates(
first: Int
after: CursorKey
last: Int
before: CursorKey
): MailingListUpdateConnection! @goField(forceResolver: true)
} }
type ComplianceExternalURL implements Node { type ComplianceExternalURL implements Node {
@@ -2539,6 +2651,7 @@ type MailingListSubscriber implements Node
model: "go.probo.inc/probo/pkg/server/api/trust/v1/types.MailingListSubscriber" model: "go.probo.inc/probo/pkg/server/api/trust/v1/types.MailingListSubscriber"
) { ) {
id: ID! id: ID!
fullName: String!
email: EmailAddr! email: EmailAddr!
status: MailingListSubscriberStatus! status: MailingListSubscriberStatus!
createdAt: Datetime! createdAt: Datetime!
@@ -2901,6 +3014,32 @@ func (ec *executionContext) field_TrustCenter_trustCenterFiles_args(ctx context.
return args, nil 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) { func (ec *executionContext) field_TrustCenter_vendors_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
var err error var err error
args := map[string]any{} args := map[string]any{}
@@ -4850,6 +4989,35 @@ func (ec *executionContext) fieldContext_MailingListSubscriber_id(_ context.Cont
return fc, nil 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) { func (ec *executionContext) _MailingListSubscriber_email(ctx context.Context, field graphql.CollectedField, obj *types.MailingListSubscriber) (ret graphql.Marshaler) {
return graphql.ResolveField( return graphql.ResolveField(
ctx, ctx,
@@ -4966,6 +5134,290 @@ func (ec *executionContext) fieldContext_MailingListSubscriber_updatedAt(_ conte
return fc, nil 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) { func (ec *executionContext) _Mutation_sendMagicLink(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
return graphql.ResolveField( return graphql.ResolveField(
ctx, ctx,
@@ -6423,6 +6875,8 @@ func (ec *executionContext) fieldContext_Query_currentTrustCenter(_ context.Cont
return ec.fieldContext_TrustCenter_complianceFrameworks(ctx, field) return ec.fieldContext_TrustCenter_complianceFrameworks(ctx, field)
case "externalUrls": case "externalUrls":
return ec.fieldContext_TrustCenter_externalUrls(ctx, field) 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) 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 { switch field.Name {
case "id": case "id":
return ec.fieldContext_MailingListSubscriber_id(ctx, field) return ec.fieldContext_MailingListSubscriber_id(ctx, field)
case "fullName":
return ec.fieldContext_MailingListSubscriber_fullName(ctx, field)
case "email": case "email":
return ec.fieldContext_MailingListSubscriber_email(ctx, field) return ec.fieldContext_MailingListSubscriber_email(ctx, field)
case "status": case "status":
@@ -7168,6 +7624,8 @@ func (ec *executionContext) fieldContext_TrustCenter_viewerSubscription(_ contex
switch field.Name { switch field.Name {
case "id": case "id":
return ec.fieldContext_MailingListSubscriber_id(ctx, field) return ec.fieldContext_MailingListSubscriber_id(ctx, field)
case "fullName":
return ec.fieldContext_MailingListSubscriber_fullName(ctx, field)
case "email": case "email":
return ec.fieldContext_MailingListSubscriber_email(ctx, field) return ec.fieldContext_MailingListSubscriber_email(ctx, field)
case "status": case "status":
@@ -7624,6 +8082,66 @@ func (ec *executionContext) fieldContext_TrustCenter_externalUrls(ctx context.Co
return fc, nil 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) { func (ec *executionContext) _TrustCenterAccess_id(ctx context.Context, field graphql.CollectedField, obj *types.TrustCenterAccess) (ret graphql.Marshaler) {
return graphql.ResolveField( return graphql.ResolveField(
ctx, ctx,
@@ -10701,6 +11219,13 @@ func (ec *executionContext) _Node(ctx context.Context, sel ast.SelectionSet, obj
return graphql.Null return graphql.Null
} }
return ec._Organization(ctx, sel, obj) 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: case types.MailingListSubscriber:
return ec._MailingListSubscriber(ctx, sel, &obj) return ec._MailingListSubscriber(ctx, sel, &obj)
case *types.MailingListSubscriber: case *types.MailingListSubscriber:
@@ -11944,6 +12469,11 @@ func (ec *executionContext) _MailingListSubscriber(ctx context.Context, sel ast.
if out.Values[i] == graphql.Null { if out.Values[i] == graphql.Null {
out.Invalids++ out.Invalids++
} }
case "fullName":
out.Values[i] = ec._MailingListSubscriber_fullName(ctx, field, obj)
if out.Values[i] == graphql.Null {
out.Invalids++
}
case "email": case "email":
out.Values[i] = ec._MailingListSubscriber_email(ctx, field, obj) out.Values[i] = ec._MailingListSubscriber_email(ctx, field, obj)
if out.Values[i] == graphql.Null { if out.Values[i] == graphql.Null {
@@ -11987,6 +12517,148 @@ func (ec *executionContext) _MailingListSubscriber(ctx context.Context, sel ast.
return out 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"} var mutationImplementors = []string{"Mutation"}
func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) graphql.Marshaler { 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 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) }) out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) })
default: default:
panic("unknown field " + strconv.Quote(field.Name)) 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 { 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 v == nil {
if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {

View File

@@ -24,6 +24,7 @@ import (
type MailingListSubscriber struct { type MailingListSubscriber struct {
ID gid.GID `json:"id"` ID gid.GID `json:"id"`
FullName string `json:"fullName"`
Email mail.Addr `json:"email"` Email mail.Addr `json:"email"`
Status coredata.MailingListSubscriberStatus `json:"status"` Status coredata.MailingListSubscriberStatus `json:"status"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
@@ -36,6 +37,7 @@ func (m MailingListSubscriber) GetID() gid.GID { return m.ID }
func NewMailingListSubscriber(s *coredata.MailingListSubscriber) *MailingListSubscriber { func NewMailingListSubscriber(s *coredata.MailingListSubscriber) *MailingListSubscriber {
return &MailingListSubscriber{ return &MailingListSubscriber{
ID: s.ID, ID: s.ID,
FullName: s.FullName,
Email: s.Email, Email: s.Email,
Status: s.Status, Status: s.Status,
CreatedAt: s.CreatedAt, 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 (Identity) IsNode() {}
func (this Identity) GetID() gid.GID { return this.ID } 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 { type Mutation struct {
} }
@@ -261,6 +281,7 @@ type TrustCenter struct {
TrustCenterFiles *TrustCenterFileConnection `json:"trustCenterFiles"` TrustCenterFiles *TrustCenterFileConnection `json:"trustCenterFiles"`
ComplianceFrameworks *ComplianceFrameworkConnection `json:"complianceFrameworks"` ComplianceFrameworks *ComplianceFrameworkConnection `json:"complianceFrameworks"`
ExternalUrls *ComplianceExternalURLConnection `json:"externalUrls"` ExternalUrls *ComplianceExternalURLConnection `json:"externalUrls"`
Updates *MailingListUpdateConnection `json:"updates"`
} }
func (TrustCenter) IsNode() {} func (TrustCenter) IsNode() {}

View File

@@ -704,19 +704,19 @@ func (r *mutationResolver) SubscribeToMailingList(ctx context.Context) (*types.S
identity := authn.IdentityFromContext(ctx) 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 err != nil {
if errors.Is(err, mailman.ErrSubscriberAlreadyExist) { if errors.Is(err, mailman.ErrSubscriberAlreadyExist) {
subscriber, err = r.mailman.GetSubscriber(ctx, *trustCenter.MailingListID, identity.EmailAddress) return nil, gqlutils.Conflictf(ctx, "already subscribed to this mailing list")
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)) r.logger.ErrorCtx(ctx, "cannot subscribe to mailing list", log.Error(err))
return nil, gqlutils.Internal(ctx) return nil, gqlutils.Internal(ctx)
} }
}
return &types.SubscribeToMailingListPayload{ return &types.SubscribeToMailingListPayload{
Subscription: types.NewMailingListSubscriber(subscriber), Subscription: types.NewMailingListSubscriber(subscriber),
@@ -738,10 +738,13 @@ func (r *mutationResolver) UnsubscribeFromMailingList(ctx context.Context) (*typ
return nil, gqlutils.Internal(ctx) return nil, gqlutils.Internal(ctx)
} }
if subscriber == nil { 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 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)) r.logger.ErrorCtx(ctx, "cannot unsubscribe from mailing list", log.Error(err))
return nil, gqlutils.Internal(ctx) return nil, gqlutils.Internal(ctx)
} }
@@ -1207,6 +1210,35 @@ func (r *trustCenterResolver) ExternalUrls(ctx context.Context, obj *types.Trust
return types.NewComplianceExternalURLConnection(result), nil 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. // IsUserAuthorized is the resolver for the isUserAuthorized field.
func (r *trustCenterFileResolver) IsUserAuthorized(ctx context.Context, obj *types.TrustCenterFile) (bool, error) { func (r *trustCenterFileResolver) IsUserAuthorized(ctx context.Context, obj *types.TrustCenterFile) (bool, error) {
trustService := r.TrustService(ctx, obj.ID.TenantID()) 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/securecookie"
"go.probo.inc/probo/pkg/server/api" "go.probo.inc/probo/pkg/server/api"
"go.probo.inc/probo/pkg/server/api/compliancepage" "go.probo.inc/probo/pkg/server/api/compliancepage"
"go.probo.inc/probo/pkg/server/mailactions"
trust_web "go.probo.inc/probo/pkg/server/trust" trust_web "go.probo.inc/probo/pkg/server/trust"
console_web "go.probo.inc/probo/pkg/server/web" console_web "go.probo.inc/probo/pkg/server/web"
"go.probo.inc/probo/pkg/slack" "go.probo.inc/probo/pkg/slack"
@@ -58,6 +59,7 @@ type Config struct {
type Server struct { type Server struct {
apiServer *api.Server apiServer *api.Server
mailActionsHandler http.Handler
consoleWebServer *console_web.Server consoleWebServer *console_web.Server
trustWebServer *trust_web.Server trustWebServer *trust_web.Server
router *chi.Mux router *chi.Mux
@@ -103,6 +105,7 @@ func NewServer(cfg Config) (*Server, error) {
server := &Server{ server := &Server{
apiServer: apiServer, apiServer: apiServer,
mailActionsHandler: mailactions.NewMux(cfg.Mailman, cfg.TokenSecret),
consoleWebServer: consoleWebServer, consoleWebServer: consoleWebServer,
trustWebServer: trustWebServer, trustWebServer: trustWebServer,
router: router, router: router,
@@ -119,6 +122,7 @@ func NewServer(cfg Config) (*Server, error) {
func (s *Server) setupRoutes(baseURL string) { func (s *Server) setupRoutes(baseURL string) {
s.router.Mount("/api", http.StripPrefix("/api", s.apiServer)) 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) { s.router.Route("/trust/{slugOrId}", func(r chi.Router) {
r.Use(compliancepage.NewIDMiddleware(s.trustService, baseURL)) r.Use(compliancepage.NewIDMiddleware(s.trustService, baseURL))

View File

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