+
+
)
}
diff --git a/clients/apps/web/src/hooks/emailUpdate.ts b/clients/apps/web/src/hooks/emailUpdate.ts
new file mode 100644
index 0000000000..13e0810cc6
--- /dev/null
+++ b/clients/apps/web/src/hooks/emailUpdate.ts
@@ -0,0 +1,21 @@
+'use client'
+
+import { api } from "@/utils/api"
+import { EmailUpdateRequest } from "@polar-sh/sdk"
+import { useRouter } from "next/navigation"
+import { useCallback } from "react"
+
+export const useSendEmailUpdate = () => {
+ const router = useRouter()
+ const func = useCallback(
+ async (email: string, return_to?: string) => {
+ const body: EmailUpdateRequest = {
+ email,
+ return_to,
+ }
+ await api.emailUpdate.requestEmailUpdate({ body })
+ },
+ [router],
+ )
+ return func
+}
diff --git a/clients/packages/sdk/src/client/.openapi-generator/FILES b/clients/packages/sdk/src/client/.openapi-generator/FILES
index 51b4a13f2e..3430f1c4d2 100644
--- a/clients/packages/sdk/src/client/.openapi-generator/FILES
+++ b/clients/packages/sdk/src/client/.openapi-generator/FILES
@@ -19,6 +19,7 @@ apis/CustomerPortalSubscriptionsApi.ts
apis/CustomersApi.ts
apis/DashboardApi.ts
apis/DiscountsApi.ts
+apis/EmailUpdateApi.ts
apis/EmbedsApi.ts
apis/ExternalOrganizationsApi.ts
apis/FilesApi.ts
diff --git a/clients/packages/sdk/src/client/PolarAPI.ts b/clients/packages/sdk/src/client/PolarAPI.ts
index 6513a7b94d..4cdaaf8066 100644
--- a/clients/packages/sdk/src/client/PolarAPI.ts
+++ b/clients/packages/sdk/src/client/PolarAPI.ts
@@ -9,6 +9,7 @@ import {
Configuration,
DashboardApi,
DiscountsApi,
+ EmailUpdateApi,
ExternalOrganizationsApi,
FilesApi,
FundingApi,
@@ -71,6 +72,7 @@ export class PolarAPI {
public readonly discounts: DiscountsApi
public readonly benefits: BenefitsApi
public readonly dashboard: DashboardApi
+ public readonly emailUpdate: EmailUpdateApi
public readonly externalOrganizations: ExternalOrganizationsApi
public readonly funding: FundingApi
public readonly integrationsDiscord: IntegrationsDiscordApi
@@ -120,6 +122,7 @@ export class PolarAPI {
this.benefits = new BenefitsApi(config)
this.dashboard = new DashboardApi(config)
this.discounts = new DiscountsApi(config)
+ this.emailUpdate = new EmailUpdateApi(config)
this.externalOrganizations = new ExternalOrganizationsApi(config)
this.funding = new FundingApi(config)
this.integrationsDiscord = new IntegrationsDiscordApi(config)
diff --git a/clients/packages/sdk/src/client/apis/EmailUpdateApi.ts b/clients/packages/sdk/src/client/apis/EmailUpdateApi.ts
new file mode 100644
index 0000000000..5a1100ced0
--- /dev/null
+++ b/clients/packages/sdk/src/client/apis/EmailUpdateApi.ts
@@ -0,0 +1,144 @@
+/* tslint:disable */
+/* eslint-disable */
+/**
+ * Polar API
+ * Read the docs at https://docs.polar.sh/api
+ *
+ * The version of the OpenAPI document: 0.1.0
+ *
+ *
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+
+import * as runtime from '../runtime';
+import type {
+ EmailUpdateRequest,
+ HTTPValidationError,
+} from '../models/index';
+
+export interface EmailUpdateApiRequestEmailUpdateRequest {
+ body: EmailUpdateRequest;
+}
+
+export interface EmailUpdateApiVerifyEmailUpdateRequest {
+ token: string;
+ returnTo?: string;
+}
+
+/**
+ *
+ */
+export class EmailUpdateApi extends runtime.BaseAPI {
+
+ /**
+ * Request Email Update
+ */
+ async requestEmailUpdateRaw(requestParameters: EmailUpdateApiRequestEmailUpdateRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> {
+ if (requestParameters['body'] == null) {
+ throw new runtime.RequiredError(
+ 'body',
+ 'Required parameter "body" was null or undefined when calling requestEmailUpdate().'
+ );
+ }
+
+ const queryParameters: any = {};
+
+ const headerParameters: runtime.HTTPHeaders = {};
+
+ headerParameters['Content-Type'] = 'application/json';
+
+ if (this.configuration && this.configuration.accessToken) {
+ const token = this.configuration.accessToken;
+ const tokenString = await token("pat", []);
+
+ if (tokenString) {
+ headerParameters["Authorization"] = `Bearer ${tokenString}`;
+ }
+ }
+ const response = await this.request({
+ path: `/v1/email-update/request`,
+ method: 'POST',
+ headers: headerParameters,
+ query: queryParameters,
+ body: requestParameters['body'],
+ }, initOverrides);
+
+ if (this.isJsonMime(response.headers.get('content-type'))) {
+ return new runtime.JSONApiResponse(response);
+ } else {
+ return new runtime.TextApiResponse(response) as any;
+ }
+ }
+
+ /**
+ * Request Email Update
+ */
+ async requestEmailUpdate(requestParameters: EmailUpdateApiRequestEmailUpdateRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise {
+ const response = await this.requestEmailUpdateRaw(requestParameters, initOverrides);
+ return await response.value();
+ }
+
+ /**
+ * Verify Email Update
+ */
+ async verifyEmailUpdateRaw(requestParameters: EmailUpdateApiVerifyEmailUpdateRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> {
+ if (requestParameters['token'] == null) {
+ throw new runtime.RequiredError(
+ 'token',
+ 'Required parameter "token" was null or undefined when calling verifyEmailUpdate().'
+ );
+ }
+
+ const queryParameters: any = {};
+
+ if (requestParameters['returnTo'] != null) {
+ queryParameters['return_to'] = requestParameters['returnTo'];
+ }
+
+ const headerParameters: runtime.HTTPHeaders = {};
+
+ const consumes: runtime.Consume[] = [
+ { contentType: 'application/x-www-form-urlencoded' },
+ ];
+ // @ts-ignore: canConsumeForm may be unused
+ const canConsumeForm = runtime.canConsumeForm(consumes);
+
+ let formParams: { append(param: string, value: any): any };
+ let useForm = false;
+ if (useForm) {
+ formParams = new FormData();
+ } else {
+ formParams = new URLSearchParams();
+ }
+
+ if (requestParameters['token'] != null) {
+ formParams.append('token', requestParameters['token'] as any);
+ }
+
+ const response = await this.request({
+ path: `/v1/email-update/verify`,
+ method: 'POST',
+ headers: headerParameters,
+ query: queryParameters,
+ body: formParams,
+ }, initOverrides);
+
+ if (this.isJsonMime(response.headers.get('content-type'))) {
+ return new runtime.JSONApiResponse(response);
+ } else {
+ return new runtime.TextApiResponse(response) as any;
+ }
+ }
+
+ /**
+ * Verify Email Update
+ */
+ async verifyEmailUpdate(requestParameters: EmailUpdateApiVerifyEmailUpdateRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise {
+ const response = await this.verifyEmailUpdateRaw(requestParameters, initOverrides);
+ return await response.value();
+ }
+
+}
diff --git a/clients/packages/sdk/src/client/apis/index.ts b/clients/packages/sdk/src/client/apis/index.ts
index 7370326bc0..db4b287e24 100644
--- a/clients/packages/sdk/src/client/apis/index.ts
+++ b/clients/packages/sdk/src/client/apis/index.ts
@@ -21,6 +21,7 @@ export * from './CustomerPortalSubscriptionsApi';
export * from './CustomersApi';
export * from './DashboardApi';
export * from './DiscountsApi';
+export * from './EmailUpdateApi';
export * from './EmbedsApi';
export * from './ExternalOrganizationsApi';
export * from './FilesApi';
diff --git a/clients/packages/sdk/src/client/models/index.ts b/clients/packages/sdk/src/client/models/index.ts
index 506dc15188..20f7c27c97 100644
--- a/clients/packages/sdk/src/client/models/index.ts
+++ b/clients/packages/sdk/src/client/models/index.ts
@@ -4041,6 +4041,12 @@ export interface Checkout {
* @memberof Checkout
*/
attached_custom_fields: Array;
+ /**
+ *
+ * @type {{ [key: string]: MetadataValue; }}
+ * @memberof Checkout
+ */
+ customer_metadata: { [key: string]: MetadataValue; };
}
@@ -4779,7 +4785,16 @@ export type CheckoutLinkSortProperty = typeof CheckoutLinkSortProperty[keyof typ
*/
export interface CheckoutLinkUpdate {
/**
+ * Key-value object allowing you to store additional information.
+ *
+ * The key must be a string with a maximum length of **40 characters**.
+ * The value must be either:
*
+ * * A string with a maximum length of **500 characters**
+ * * An integer
+ * * A boolean
+ *
+ * You can store up to **50 key-value pairs**.
* @type {{ [key: string]: MetadataValue1; }}
* @memberof CheckoutLinkUpdate
*/
@@ -4905,6 +4920,21 @@ export interface CheckoutPriceCreate {
* @memberof CheckoutPriceCreate
*/
customer_tax_id?: string | null;
+ /**
+ * Key-value object allowing you to store additional information that'll be copied to the created customer.
+ *
+ * The key must be a string with a maximum length of **40 characters**.
+ * The value must be either:
+ *
+ * * A string with a maximum length of **500 characters**
+ * * An integer
+ * * A boolean
+ *
+ * You can store up to **50 key-value pairs**.
+ * @type {{ [key: string]: MetadataValue1; }}
+ * @memberof CheckoutPriceCreate
+ */
+ customer_metadata?: { [key: string]: MetadataValue1; };
/**
*
* @type {string}
@@ -5103,6 +5133,21 @@ export interface CheckoutProductCreate {
* @memberof CheckoutProductCreate
*/
customer_tax_id?: string | null;
+ /**
+ * Key-value object allowing you to store additional information that'll be copied to the created customer.
+ *
+ * The key must be a string with a maximum length of **40 characters**.
+ * The value must be either:
+ *
+ * * A string with a maximum length of **500 characters**
+ * * An integer
+ * * A boolean
+ *
+ * You can store up to **50 key-value pairs**.
+ * @type {{ [key: string]: MetadataValue1; }}
+ * @memberof CheckoutProductCreate
+ */
+ customer_metadata?: { [key: string]: MetadataValue1; };
/**
*
* @type {string}
@@ -5693,7 +5738,16 @@ export interface CheckoutUpdate {
*/
customer_tax_id?: string | null;
/**
+ * Key-value object allowing you to store additional information.
+ *
+ * The key must be a string with a maximum length of **40 characters**.
+ * The value must be either:
+ *
+ * * A string with a maximum length of **500 characters**
+ * * An integer
+ * * A boolean
*
+ * You can store up to **50 key-value pairs**.
* @type {{ [key: string]: MetadataValue1; }}
* @memberof CheckoutUpdate
*/
@@ -5716,6 +5770,21 @@ export interface CheckoutUpdate {
* @memberof CheckoutUpdate
*/
customer_ip_address?: string | null;
+ /**
+ * Key-value object allowing you to store additional information.
+ *
+ * The key must be a string with a maximum length of **40 characters**.
+ * The value must be either:
+ *
+ * * A string with a maximum length of **500 characters**
+ * * An integer
+ * * A boolean
+ *
+ * You can store up to **50 key-value pairs**.
+ * @type {{ [key: string]: MetadataValue1; }}
+ * @memberof CheckoutUpdate
+ */
+ customer_metadata?: { [key: string]: MetadataValue1; } | null;
/**
*
* @type {string}
@@ -6811,7 +6880,16 @@ export type CustomFieldUpdate = { type: 'checkbox' } & CustomFieldUpdateCheckbox
*/
export interface CustomFieldUpdateCheckbox {
/**
+ * Key-value object allowing you to store additional information.
+ *
+ * The key must be a string with a maximum length of **40 characters**.
+ * The value must be either:
+ *
+ * * A string with a maximum length of **500 characters**
+ * * An integer
+ * * A boolean
*
+ * You can store up to **50 key-value pairs**.
* @type {{ [key: string]: MetadataValue1; }}
* @memberof CustomFieldUpdateCheckbox
*/
@@ -6858,7 +6936,16 @@ export type CustomFieldUpdateCheckboxTypeEnum = typeof CustomFieldUpdateCheckbox
*/
export interface CustomFieldUpdateDate {
/**
+ * Key-value object allowing you to store additional information.
+ *
+ * The key must be a string with a maximum length of **40 characters**.
+ * The value must be either:
+ *
+ * * A string with a maximum length of **500 characters**
+ * * An integer
+ * * A boolean
*
+ * You can store up to **50 key-value pairs**.
* @type {{ [key: string]: MetadataValue1; }}
* @memberof CustomFieldUpdateDate
*/
@@ -6905,7 +6992,16 @@ export type CustomFieldUpdateDateTypeEnum = typeof CustomFieldUpdateDateTypeEnum
*/
export interface CustomFieldUpdateNumber {
/**
+ * Key-value object allowing you to store additional information.
+ *
+ * The key must be a string with a maximum length of **40 characters**.
+ * The value must be either:
+ *
+ * * A string with a maximum length of **500 characters**
+ * * An integer
+ * * A boolean
*
+ * You can store up to **50 key-value pairs**.
* @type {{ [key: string]: MetadataValue1; }}
* @memberof CustomFieldUpdateNumber
*/
@@ -6952,7 +7048,16 @@ export type CustomFieldUpdateNumberTypeEnum = typeof CustomFieldUpdateNumberType
*/
export interface CustomFieldUpdateSelect {
/**
+ * Key-value object allowing you to store additional information.
+ *
+ * The key must be a string with a maximum length of **40 characters**.
+ * The value must be either:
+ *
+ * * A string with a maximum length of **500 characters**
+ * * An integer
+ * * A boolean
*
+ * You can store up to **50 key-value pairs**.
* @type {{ [key: string]: MetadataValue1; }}
* @memberof CustomFieldUpdateSelect
*/
@@ -6999,7 +7104,16 @@ export type CustomFieldUpdateSelectTypeEnum = typeof CustomFieldUpdateSelectType
*/
export interface CustomFieldUpdateText {
/**
+ * Key-value object allowing you to store additional information.
+ *
+ * The key must be a string with a maximum length of **40 characters**.
+ * The value must be either:
+ *
+ * * A string with a maximum length of **500 characters**
+ * * An integer
+ * * A boolean
*
+ * You can store up to **50 key-value pairs**.
* @type {{ [key: string]: MetadataValue1; }}
* @memberof CustomFieldUpdateText
*/
@@ -9989,7 +10103,16 @@ export type DiscountType = typeof DiscountType[keyof typeof DiscountType];
*/
export interface DiscountUpdate {
/**
+ * Key-value object allowing you to store additional information.
+ *
+ * The key must be a string with a maximum length of **40 characters**.
+ * The value must be either:
+ *
+ * * A string with a maximum length of **500 characters**
+ * * An integer
+ * * A boolean
*
+ * You can store up to **50 key-value pairs**.
* @type {{ [key: string]: MetadataValue1; }}
* @memberof DiscountUpdate
*/
@@ -10306,6 +10429,25 @@ export interface DownloadableRead {
*/
file: FileDownload;
}
+/**
+ *
+ * @export
+ * @interface EmailUpdateRequest
+ */
+export interface EmailUpdateRequest {
+ /**
+ *
+ * @type {string}
+ * @memberof EmailUpdateRequest
+ */
+ email: string;
+ /**
+ *
+ * @type {string}
+ * @memberof EmailUpdateRequest
+ */
+ return_to?: string | null;
+}
/**
*
* @export
@@ -17536,7 +17678,16 @@ export interface ProductStorefront {
*/
export interface ProductUpdate {
/**
+ * Key-value object allowing you to store additional information.
*
+ * The key must be a string with a maximum length of **40 characters**.
+ * The value must be either:
+ *
+ * * A string with a maximum length of **500 characters**
+ * * An integer
+ * * A boolean
+ *
+ * You can store up to **50 key-value pairs**.
* @type {{ [key: string]: MetadataValue1; }}
* @memberof ProductUpdate
*/
diff --git a/server/migrations/versions/2024-12-18-1130_add_emailverification.py b/server/migrations/versions/2024-12-18-1130_add_emailverification.py
new file mode 100644
index 0000000000..49ad193bb9
--- /dev/null
+++ b/server/migrations/versions/2024-12-18-1130_add_emailverification.py
@@ -0,0 +1,83 @@
+"""Add EmailVerification
+
+Revision ID: eaf307b21bd9
+Revises: cb9906114207
+Create Date: 2024-12-16 11:30:02.693730
+
+"""
+
+import sqlalchemy as sa
+from alembic import op
+
+# Polar Custom Imports
+
+# revision identifiers, used by Alembic.
+revision = "eaf307b21bd9"
+down_revision = "cb9906114207"
+branch_labels: tuple[str] | None = None
+depends_on: tuple[str] | None = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table(
+ "email_verification",
+ sa.Column("email", sa.String(), nullable=False),
+ sa.Column("token_hash", sa.String(), nullable=False),
+ sa.Column("expires_at", sa.TIMESTAMP(timezone=True), nullable=False),
+ sa.Column("user_id", sa.Uuid(), nullable=False),
+ sa.Column("id", sa.Uuid(), nullable=False),
+ sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False),
+ sa.Column("modified_at", sa.TIMESTAMP(timezone=True), nullable=True),
+ sa.Column("deleted_at", sa.TIMESTAMP(timezone=True), nullable=True),
+ sa.ForeignKeyConstraint(
+ ["user_id"],
+ ["users.id"],
+ name=op.f("email_verification_user_id_fkey"),
+ ondelete="cascade",
+ ),
+ sa.PrimaryKeyConstraint("id", name=op.f("email_verification_pkey")),
+ )
+ op.create_index(
+ op.f("ix_email_verification_created_at"),
+ "email_verification",
+ ["created_at"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_email_verification_deleted_at"),
+ "email_verification",
+ ["deleted_at"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_email_verification_modified_at"),
+ "email_verification",
+ ["modified_at"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_email_verification_token_hash"),
+ "email_verification",
+ ["token_hash"],
+ unique=False,
+ )
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_index(
+ op.f("ix_email_verification_token_hash"), table_name="email_verification"
+ )
+ op.drop_index(
+ op.f("ix_email_verification_modified_at"), table_name="email_verification"
+ )
+ op.drop_index(
+ op.f("ix_email_verification_deleted_at"), table_name="email_verification"
+ )
+ op.drop_index(
+ op.f("ix_email_verification_created_at"), table_name="email_verification"
+ )
+ op.drop_table("email_verification")
+ # ### end Alembic commands ###
diff --git a/server/polar/api.py b/server/polar/api.py
index 7790ab9d28..64ee290f98 100644
--- a/server/polar/api.py
+++ b/server/polar/api.py
@@ -13,6 +13,7 @@
from polar.customer_portal.endpoints import router as customer_portal_router
from polar.dashboard.endpoints import router as dashboard_router
from polar.discount.endpoints import router as discount_router
+from polar.email_update.endpoints import router as email_update_router
from polar.embed.endpoints import router as embed_router
from polar.eventstream.endpoints import router as stream_router
from polar.external_organization.endpoints import router as external_organization_router
@@ -132,3 +133,5 @@
router.include_router(customer_router)
# /customer-portal
router.include_router(customer_portal_router)
+# /update-email
+router.include_router(email_update_router)
diff --git a/server/polar/config.py b/server/polar/config.py
index a3ee236818..cecc77f1e9 100644
--- a/server/polar/config.py
+++ b/server/polar/config.py
@@ -70,6 +70,9 @@ class Settings(BaseSettings):
# Magic link
MAGIC_LINK_TTL_SECONDS: int = 60 * 30 # 30 minutes
+ # Email verification
+ EMAIL_VERIFICATION_TTL_SECONDS: int = 60 * 30 # 30 minutes
+
# Checkout
CHECKOUT_TTL_SECONDS: int = 60 * 60 # 1 hour
IP_GEOLOCATION_DATABASE_DIRECTORY_PATH: DirectoryPath = Path(__file__).parent.parent
diff --git a/server/polar/email_update/__init__.py b/server/polar/email_update/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/server/polar/email_update/email_templates/email_update.html b/server/polar/email_update/email_templates/email_update.html
new file mode 100644
index 0000000000..e163f21bf4
--- /dev/null
+++ b/server/polar/email_update/email_templates/email_update.html
@@ -0,0 +1,33 @@
+{% extends "base.html" %}
+
+{% block body %}
+
Hi,
+
Here is the verification link to update your email. Click the button below to complete the update process. This link
+ is only valid for the next {{ token_lifetime_minutes }} minutes.