Browse Source

Add new privacy option 'Record opt-in IP' to record IP address of optin confirmation.

- Add new 'Subscriptions' table on the subscriber list form that shows subs,
  IP, and other data.
- Add new `meta` JSONB field to `subscriber_lsts` table.

Closes #1329.
Kailash Nadh 2 years ago
parent
commit
ad80c716f9

+ 1 - 0
cmd/init.go

@@ -69,6 +69,7 @@ type constants struct {
 		AllowBlocklist     bool            `koanf:"allow_blocklist"`
 		AllowExport        bool            `koanf:"allow_export"`
 		AllowWipe          bool            `koanf:"allow_wipe"`
+		RecordOptinIP      bool            `koanf:"record_optin_ip"`
 		Exportable         map[string]bool `koanf:"-"`
 		DomainBlocklist    []string        `koanf:"-"`
 	} `koanf:"privacy"`

+ 10 - 1
cmd/public.go

@@ -374,7 +374,16 @@ func handleOptinPage(c echo.Context) error {
 
 	// Confirm.
 	if confirm {
-		if err := app.core.ConfirmOptionSubscription(subUUID, out.ListUUIDs); err != nil {
+		meta := models.JSON{}
+		if app.constants.Privacy.RecordOptinIP {
+			if h := c.Request().Header.Get("X-Forwarded-For"); h != "" {
+				meta["optin_ip"] = h
+			} else if h := c.Request().RemoteAddr; h != "" {
+				meta["optin_ip"] = strings.Split(h, ":")[0]
+			}
+		}
+
+		if err := app.core.ConfirmOptionSubscription(subUUID, out.ListUUIDs, meta); err != nil {
 			app.log.Printf("error unsubscribing: %v", err)
 			return c.Render(http.StatusInternalServerError, tplMessage,
 				makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorProcessingRequest")))

+ 34 - 32
frontend/src/views/SubscriberForm.vue

@@ -75,39 +75,41 @@
           </div>
         </b-field>
 
-        <div>
+        <div class="mb-5" v-if="data.lists">
           <h5>{{ $tc('globals.terms.subscriptions', 2) }} ({{ data.lists.length }})</h5>
-          <div class="mb-5">
-            <b-table :data="data.lists" hoverable default-sort="createdAt" class="subscriptions"
-            >
-              <b-table-column v-slot="props" field="name"
-                :label="$tc('globals.terms.list', 1)">
-                <div>
-                  <router-link :to="`/lists/${props.row.id}`">
-                    {{ props.row.name }}
-                  </router-link>
-                  <br />
-                  <b-tag :class="props.row.optin" :data-cy="`optin-${props.row.optin}`">
-                    <b-icon :icon="props.row.optin === 'double' ?
-                      'account-check-outline' : 'account-off-outline'" size="is-small" />
-                    {{ ' ' }}
-                    {{ $t(`lists.optins.${props.row.optin}`) }}
-                  </b-tag>{{ ' ' }}
-                </div>
-              </b-table-column>
-              <b-table-column v-slot="props" field="status" :label="$t('globals.fields.status')">
-                {{ props.row.optin === 'double' ? props.row.subscriptionStatus : '-' }}
-              </b-table-column>
-              <b-table-column v-slot="props" field="createdAt"
-                :label="$t('globals.fields.createdAt')">
-                {{ $utils.niceDate(props.row.subscriptionCreatedAt, true) }}
-              </b-table-column>
-              <b-table-column v-slot="props" field="updatedAt"
-                :label="$t('globals.fields.updatedAt')">
-                {{ $utils.niceDate(props.row.subscriptionCreatedAt, true) }}
-              </b-table-column>
-            </b-table>
-          </div>
+          <b-table :data="data.lists" hoverable default-sort="createdAt" class="subscriptions"
+          >
+            <b-table-column v-slot="props" field="name"
+              :label="$tc('globals.terms.list', 1)">
+              <div>
+                <router-link :to="`/lists/${props.row.id}`">
+                  {{ props.row.name }}
+                </router-link>
+                <br />
+                <b-tag :class="props.row.optin" :data-cy="`optin-${props.row.optin}`">
+                  <b-icon :icon="props.row.optin === 'double' ?
+                    'account-check-outline' : 'account-off-outline'" size="is-small" />
+                  {{ ' ' }}
+                  {{ $t(`lists.optins.${props.row.optin}`) }}
+                </b-tag>{{ ' ' }}
+              </div>
+            </b-table-column>
+            <b-table-column v-slot="props" field="status" :label="$t('globals.fields.status')">
+              {{ props.row.optin === 'double' ? props.row.subscriptionStatus : '-' }}
+              <template v-if="props.row.optin === 'double'
+                && props.row.subscriptionMeta.optinIp">
+                <br /><span class="is-size-7">{{ props.row.subscriptionMeta.optinIp }}</span>
+              </template>
+            </b-table-column>
+            <b-table-column v-slot="props" field="createdAt"
+              :label="$t('globals.fields.createdAt')">
+              {{ $utils.niceDate(props.row.subscriptionCreatedAt, true) }}
+            </b-table-column>
+            <b-table-column v-slot="props" field="updatedAt"
+              :label="$t('globals.fields.updatedAt')">
+              {{ $utils.niceDate(props.row.subscriptionCreatedAt, true) }}
+            </b-table-column>
+          </b-table>
         </div>
 
         <div class="bounces" v-show="bounces.length > 0">

+ 6 - 0
frontend/src/views/settings/privacy.vue

@@ -36,6 +36,12 @@
           name="privacy.allow_wipe" />
     </b-field>
 
+    <b-field :label="$t('settings.privacy.recordOptinIP')"
+      :message="$t('settings.privacy.recordOptinIPHelp')">
+      <b-switch v-model="data['privacy.record_optin_ip']"
+          name="privacy.record_optin_ip" />
+    </b-field>
+
     <b-field :label="$t('settings.privacy.domainBlocklist')"
       :message="$t('settings.privacy.domainBlocklistHelp')">
       <b-input type="textarea"

+ 2 - 0
i18n/ca.json

@@ -492,6 +492,8 @@
     "settings.privacy.listUnsubHeader": "Inclou la capçalera `List-Unsubscribe`",
     "settings.privacy.listUnsubHeaderHelp": "Inclou capçaleres de cancel·lació de subscripció que permetin als clients de correu electrònic permetre als usuaris donar-se de baixa amb un sol clic.",
     "settings.privacy.name": "Privadesa",
+    "settings.privacy.recordOptinIP": "Record opt-in IP address",
+    "settings.privacy.recordOptinIPHelp": "Record IP address of double opt-ins in subscriber attributes.",
     "settings.restart": "Reinicia",
     "settings.security.captchaKey": "hCaptcha.com SiteKey",
     "settings.security.captchaKeyHelp": "Visit www.hcaptcha.com to obtain the key and secret.",

+ 2 - 0
i18n/cs-cz.json

@@ -492,6 +492,8 @@
     "settings.privacy.listUnsubHeader": "Zahrnout záhlaví `List-Unsubscribe`",
     "settings.privacy.listUnsubHeaderHelp": "Zahrnout záhlaví zrušení odběrů, která umožňují e-mailovým klientům, aby povolili uživatelům zrušit odběr jediným klepnutím.",
     "settings.privacy.name": "Soukromí",
+    "settings.privacy.recordOptinIP": "Record opt-in IP address",
+    "settings.privacy.recordOptinIPHelp": "Record IP address of double opt-ins in subscriber attributes.",
     "settings.restart": "Restart",
     "settings.security.captchaKey": "hCaptcha.com SiteKey",
     "settings.security.captchaKeyHelp": "Visit www.hcaptcha.com to obtain the key and secret.",

+ 2 - 0
i18n/cy.json

@@ -492,6 +492,8 @@
     "settings.privacy.listUnsubHeader": "Cynnwys y pennawd 'Dad-danysgrifio o'r rhestr'",
     "settings.privacy.listUnsubHeaderHelp": "Cynnwys penynnau dad-danysgrifio sy'n caniatáu i ddefnyddwyr dad-danysgrifio drwy glicio un botwm.",
     "settings.privacy.name": "Preifatrwydd",
+    "settings.privacy.recordOptinIP": "Record opt-in IP address",
+    "settings.privacy.recordOptinIPHelp": "Record IP address of double opt-ins in subscriber attributes.",
     "settings.restart": "Ailgychwyn",
     "settings.security.captchaKey": "hCaptcha.com SiteKey",
     "settings.security.captchaKeyHelp": "Visit www.hcaptcha.com to obtain the key and secret.",

+ 2 - 0
i18n/de.json

@@ -492,6 +492,8 @@
     "settings.privacy.listUnsubHeader": "Inkludiere `List-Unsubscribe` (von Liste abmelden) Header",
     "settings.privacy.listUnsubHeaderHelp": "Inkludiere Header zum einfachen Abmelden in den E-Mails. Erlaubt es, den E-Mail Clients der Nutzer eine \",Ein Klick\"-Abmeldung anzubieten.",
     "settings.privacy.name": "Privatsphäre",
+    "settings.privacy.recordOptinIP": "Record opt-in IP address",
+    "settings.privacy.recordOptinIPHelp": "Record IP address of double opt-ins in subscriber attributes.",
     "settings.restart": "Neustarten",
     "settings.security.captchaKey": "hCaptcha.com SiteKey",
     "settings.security.captchaKeyHelp": "Visit www.hcaptcha.com to obtain the key and secret.",

+ 2 - 0
i18n/en.json

@@ -488,6 +488,8 @@
     "settings.privacy.listUnsubHeader": "Include `List-Unsubscribe` header",
     "settings.privacy.listUnsubHeaderHelp": "Include unsubscription headers that allow e-mail clients to allow users to unsubscribe in a single click.",
     "settings.privacy.name": "Privacy",
+    "settings.privacy.recordOptinIP": "Record opt-in IP address",
+    "settings.privacy.recordOptinIPHelp": "Record IP address of double opt-ins in subscriber attributes.",
     "settings.restart": "Restart",
     "settings.security.captchaKey": "hCaptcha.com SiteKey",
     "settings.security.captchaKeyHelp": "Visit www.hcaptcha.com to obtain the key and secret.",

+ 2 - 0
i18n/es.json

@@ -493,6 +493,8 @@
     "settings.privacy.listUnsubHeader": "Incluir el encabezado para `darse de baja` de la lista",
     "settings.privacy.listUnsubHeaderHelp": "Incluye los encabezados de darse de baja para habilitar a los clientes de correo para permitir a los usuarios darse de baja con un solo clic.",
     "settings.privacy.name": "Privacidad",
+    "settings.privacy.recordOptinIP": "Record opt-in IP address",
+    "settings.privacy.recordOptinIPHelp": "Record IP address of double opt-ins in subscriber attributes.",
     "settings.restart": "Reiniciar",
     "settings.security.captchaKey": "hCaptcha.com SiteKey",
     "settings.security.captchaKeyHelp": "Visite www.hcaptcha.com para conseguir la SiteKey y el secret.",

+ 2 - 0
i18n/fi.json

@@ -493,6 +493,8 @@
     "settings.privacy.listUnsubHeader": "Include `List-Unsubscribe` header",
     "settings.privacy.listUnsubHeaderHelp": "Include unsubscription headers that allow e-mail clients to allow users to unsubscribe in a single click.",
     "settings.privacy.name": "Yksityisyys",
+    "settings.privacy.recordOptinIP": "Record opt-in IP address",
+    "settings.privacy.recordOptinIPHelp": "Record IP address of double opt-ins in subscriber attributes.",
     "settings.restart": "Restart",
     "settings.security.captchaKey": "hCaptcha.com SiteKey",
     "settings.security.captchaKeyHelp": "Visit www.hcaptcha.com to obtain the key and secret.",

+ 2 - 0
i18n/fr.json

@@ -493,6 +493,8 @@
     "settings.privacy.listUnsubHeader": "Inclure l'en-tête de désabonnement simplifié (via certaines messageries)",
     "settings.privacy.listUnsubHeaderHelp": "Inclure des en-têtes de désabonnement qui permettent aux utilisateurs de se désabonner en un seul clic depuis leur client de messagerie.",
     "settings.privacy.name": "Vie privée",
+    "settings.privacy.recordOptinIP": "Record opt-in IP address",
+    "settings.privacy.recordOptinIPHelp": "Record IP address of double opt-ins in subscriber attributes.",
     "settings.restart": "Redémarrer",
     "settings.security.captchaKey": "hCaptcha.com SiteKey",
     "settings.security.captchaKeyHelp": "Allez sur www.hcaptcha.com pour obtenir une clef et son secret.",

+ 2 - 0
i18n/hu.json

@@ -492,6 +492,8 @@
     "settings.privacy.listUnsubHeader": "`List-Unsubscribe` fejléc",
     "settings.privacy.listUnsubHeaderHelp": "Ha be van kapcsolva, egyes e-mail kliensek lehetővé teszik az egykattintásos leiratkozást.",
     "settings.privacy.name": "Adatvédelem",
+    "settings.privacy.recordOptinIP": "Record opt-in IP address",
+    "settings.privacy.recordOptinIPHelp": "Record IP address of double opt-ins in subscriber attributes.",
     "settings.restart": "Újraindítás",
     "settings.security.captchaKey": "hCaptcha.com kulcs",
     "settings.security.captchaKeyHelp": "Kulcs és jelszó igénylése a hcaptcha.com oldalon.",

+ 2 - 0
i18n/it.json

@@ -493,6 +493,8 @@
     "settings.privacy.listUnsubHeader": "Includere l'intestazione `List-Unsubscribe`",
     "settings.privacy.listUnsubHeaderHelp": "Includere intestazioni di annullamento dell'iscrizione che consentono agli utenti di annullare l'iscrizione con un clic dal proprio client di posta elettronica.",
     "settings.privacy.name": "Privacy",
+    "settings.privacy.recordOptinIP": "Record opt-in IP address",
+    "settings.privacy.recordOptinIPHelp": "Record IP address of double opt-ins in subscriber attributes.",
     "settings.restart": "Riavviare",
     "settings.security.captchaKey": "hCaptcha.com SiteKey",
     "settings.security.captchaKeyHelp": "Visita www.hcaptcha.com per ottenere la SiteKey anche il secret.",

+ 2 - 0
i18n/jp.json

@@ -493,6 +493,8 @@
     "settings.privacy.listUnsubHeader": "`リスト-登録解除` ヘッダー",
     "settings.privacy.listUnsubHeaderHelp": "メールクライアントがワンクリックで登録解除をできるように登録解除用のヘッダーを含める。",
     "settings.privacy.name": "プライバシー",
+    "settings.privacy.recordOptinIP": "Record opt-in IP address",
+    "settings.privacy.recordOptinIPHelp": "Record IP address of double opt-ins in subscriber attributes.",
     "settings.restart": "再起動",
     "settings.security.captchaKey": "hCaptcha.com SiteKey",
     "settings.security.captchaKeyHelp": "Visit www.hcaptcha.com to obtain the key and secret.",

+ 2 - 0
i18n/ml.json

@@ -492,6 +492,8 @@
     "settings.privacy.listUnsubHeader": "`List-Unsubscribe` തലക്കെട്ട് കൂട്ടിച്ചേർക്കുക",
     "settings.privacy.listUnsubHeaderHelp": "ഒറ്റ ക്ലിക്കിലൂടെ വരിക്കാനല്ലാതാക്കാൻ ഇ-മെയിൽ ക്ലൈന്റിൽ വരിക്കാരനല്ലാതാക്കാനുള്ള തലക്കെട്ട് കൂട്ടിച്ചേർക്കുക.",
     "settings.privacy.name": "സ്വകാര്യത",
+    "settings.privacy.recordOptinIP": "Record opt-in IP address",
+    "settings.privacy.recordOptinIPHelp": "Record IP address of double opt-ins in subscriber attributes.",
     "settings.restart": "പുനരാരംഭിയ്ക്കുക",
     "settings.security.captchaKey": "hCaptcha.com SiteKey",
     "settings.security.captchaKeyHelp": "Visit www.hcaptcha.com to obtain the key and secret.",

+ 2 - 0
i18n/nl.json

@@ -492,6 +492,8 @@
     "settings.privacy.listUnsubHeader": "Voeg `List-Unsubscribe` header toe",
     "settings.privacy.listUnsubHeaderHelp": "Voeg header toe zodat e-mailprogramma's gebruikers zich kunnen laten uitschrijven in een klik.",
     "settings.privacy.name": "Privacy",
+    "settings.privacy.recordOptinIP": "Record opt-in IP address",
+    "settings.privacy.recordOptinIPHelp": "Record IP address of double opt-ins in subscriber attributes.",
     "settings.restart": "Herstarten",
     "settings.security.captchaKey": "hCaptcha.com SiteKey",
     "settings.security.captchaKeyHelp": "Visit www.hcaptcha.com to obtain the key and secret.",

+ 2 - 0
i18n/pl.json

@@ -492,6 +492,8 @@
     "settings.privacy.listUnsubHeader": "Dodawaj nagłówek `List-Unsubscribe`",
     "settings.privacy.listUnsubHeaderHelp": "Dodaj nagłówki do wypisania się z subskrypcji. Niektóre programy pocztowe umożliwiają wypisanie się jednym kliknięciem.",
     "settings.privacy.name": "Prywatność",
+    "settings.privacy.recordOptinIP": "Record opt-in IP address",
+    "settings.privacy.recordOptinIPHelp": "Record IP address of double opt-ins in subscriber attributes.",
     "settings.restart": "Restart",
     "settings.security.captchaKey": "hCaptcha.com SiteKey",
     "settings.security.captchaKeyHelp": "Wejdź na www.hcaptcha.com w celu pobrania klucza i sekretu.",

+ 2 - 0
i18n/pt-BR.json

@@ -492,6 +492,8 @@
     "settings.privacy.listUnsubHeader": "Incluir cabeçalho `List-Unsubscribe`",
     "settings.privacy.listUnsubHeaderHelp": "Incluir cabeçalhos de desinscrição que permitem aos clientes de e-mail cancelem a inscrição em um único clique.",
     "settings.privacy.name": "Privacidade",
+    "settings.privacy.recordOptinIP": "Record opt-in IP address",
+    "settings.privacy.recordOptinIPHelp": "Record IP address of double opt-ins in subscriber attributes.",
     "settings.restart": "Reiniciar",
     "settings.security.captchaKey": "hCaptcha.com SiteKey",
     "settings.security.captchaKeyHelp": "Visit www.hcaptcha.com to obtain the key and secret.",

+ 2 - 0
i18n/pt.json

@@ -492,6 +492,8 @@
     "settings.privacy.listUnsubHeader": "Incluir header `List-Unsubscribe`",
     "settings.privacy.listUnsubHeaderHelp": "Incluir headers de cancelamento de subscrição que permite aos clientes de email permitir ao utilizadores cancelar a subscrição num único clique.",
     "settings.privacy.name": "Privacidade",
+    "settings.privacy.recordOptinIP": "Record opt-in IP address",
+    "settings.privacy.recordOptinIPHelp": "Record IP address of double opt-ins in subscriber attributes.",
     "settings.restart": "Reiniciar",
     "settings.security.captchaKey": "hCaptcha.com SiteKey",
     "settings.security.captchaKeyHelp": "Visite www.hcaptcha.com para obter a chave e o segredo.",

+ 2 - 0
i18n/ro.json

@@ -493,6 +493,8 @@
     "settings.privacy.listUnsubHeader": "Includeți antetul \"Listă-Dezabonare\"",
     "settings.privacy.listUnsubHeaderHelp": "Include anteturi de dezabonare care permit clienților de e-mail să permită utilizatorilor să se dezaboneze printr-un singur clic.",
     "settings.privacy.name": "Confidențialitate",
+    "settings.privacy.recordOptinIP": "Record opt-in IP address",
+    "settings.privacy.recordOptinIPHelp": "Record IP address of double opt-ins in subscriber attributes.",
     "settings.restart": "Repornește",
     "settings.security.captchaKey": "hCaptcha.com SiteKey",
     "settings.security.captchaKeyHelp": "Vizitați www.hcaptcha.com pentru a obține cheia și secretul.",

+ 2 - 0
i18n/ru.json

@@ -492,6 +492,8 @@
     "settings.privacy.listUnsubHeader": "Включать заголовок `List-Unsubscribe`",
     "settings.privacy.listUnsubHeaderHelp": "Включать заголовок отписки",
     "settings.privacy.name": "Конфиденциальност",
+    "settings.privacy.recordOptinIP": "Record opt-in IP address",
+    "settings.privacy.recordOptinIPHelp": "Record IP address of double opt-ins in subscriber attributes.",
     "settings.restart": "Перезапустить",
     "settings.security.captchaKey": "hCaptcha.com ключ сайта",
     "settings.security.captchaKeyHelp": "Посетите www.hcaptcha.com для получения ключа сайта и секретного ключа.",

+ 2 - 0
i18n/se.json

@@ -492,6 +492,8 @@
     "settings.privacy.listUnsubHeader": "Include `List-Unsubscribe` header",
     "settings.privacy.listUnsubHeaderHelp": "Include unsubscription headers that allow e-mail clients to allow users to unsubscribe in a single click.",
     "settings.privacy.name": "Privacy",
+    "settings.privacy.recordOptinIP": "Record opt-in IP address",
+    "settings.privacy.recordOptinIPHelp": "Record IP address of double opt-ins in subscriber attributes.",
     "settings.restart": "Restart",
     "settings.security.captchaKey": "hCaptcha.com SiteKey",
     "settings.security.captchaKeyHelp": "Visit www.hcaptcha.com to obtain the key and secret.",

+ 2 - 0
i18n/sk.json

@@ -492,6 +492,8 @@
     "settings.privacy.listUnsubHeader": "Nastaviť hlavičku `List-Unsubscribe`",
     "settings.privacy.listUnsubHeaderHelp": "Nastaví hlavičku zrušenia odberov, ktorá umožňuje e-mailovým klientom, aby povolili používateľom zrušiť odber jedným kliknutím.",
     "settings.privacy.name": "Súkromie",
+    "settings.privacy.recordOptinIP": "Record opt-in IP address",
+    "settings.privacy.recordOptinIPHelp": "Record IP address of double opt-ins in subscriber attributes.",
     "settings.restart": "Restarť",
     "settings.security.captchaKey": "hCaptcha.com SiteKey",
     "settings.security.captchaKeyHelp": "Visit www.hcaptcha.com to obtain the key and secret.",

+ 2 - 0
i18n/tr.json

@@ -493,6 +493,8 @@
     "settings.privacy.listUnsubHeader": " `List-Unsubscribe` Başlık bilgisini ekle",
     "settings.privacy.listUnsubHeaderHelp": "E-posta istemcilerinin kullanıcıların tek bir tıklamayla abonelikten çıkmalarına olanak tanıyan abonelik iptal başlıklarını ekleyin.",
     "settings.privacy.name": "Gizlilik",
+    "settings.privacy.recordOptinIP": "Record opt-in IP address",
+    "settings.privacy.recordOptinIPHelp": "Record IP address of double opt-ins in subscriber attributes.",
     "settings.restart": "Yeniden başlat",
     "settings.security.captchaKey": "hCaptcha.com SiteKey",
     "settings.security.captchaKeyHelp": "Visit www.hcaptcha.com to obtain the key and secret.",

+ 2 - 0
i18n/vi.json

@@ -493,6 +493,8 @@
     "settings.privacy.listUnsubHeader": "Bao gồm tiêu đề `Danh sách-Hủy đăng ký`",
     "settings.privacy.listUnsubHeaderHelp": "Bao gồm các tiêu đề hủy đăng ký cho phép ứng dụng e-mail cho phép người dùng hủy đăng ký chỉ bằng một cú nhấp chuột.",
     "settings.privacy.name": "Sự riêng tư",
+    "settings.privacy.recordOptinIP": "Record opt-in IP address",
+    "settings.privacy.recordOptinIPHelp": "Record IP address of double opt-ins in subscriber attributes.",
     "settings.restart": "Khởi động lại",
     "settings.security.captchaKey": "hCaptcha.com SiteKey",
     "settings.security.captchaKeyHelp": "Visit www.hcaptcha.com to obtain the key and secret.",

+ 2 - 0
i18n/zh-CN.json

@@ -492,6 +492,8 @@
     "settings.privacy.listUnsubHeader": "包括 `List-Unsubscribe` 标头",
     "settings.privacy.listUnsubHeaderHelp": "包括允许电子邮件客户端允许用户通过单击取消订阅的取消订阅标题",
     "settings.privacy.name": "隐私",
+    "settings.privacy.recordOptinIP": "Record opt-in IP address",
+    "settings.privacy.recordOptinIPHelp": "Record IP address of double opt-ins in subscriber attributes.",
     "settings.restart": "重新开始",
     "settings.security.captchaKey": "hCaptcha.com SiteKey",
     "settings.security.captchaKeyHelp": "Visit www.hcaptcha.com to obtain the key and secret.",

+ 2 - 0
i18n/zh-TW.json

@@ -493,6 +493,8 @@
     "settings.privacy.listUnsubHeader": "包括`List-Unsubscribe` 標頭",
     "settings.privacy.listUnsubHeaderHelp": "包括允許電子郵件客戶端允許用戶通過單擊取消訂閱的取消訂閱標題",
     "settings.privacy.name": "隱私",
+    "settings.privacy.recordOptinIP": "Record opt-in IP address",
+    "settings.privacy.recordOptinIPHelp": "Record IP address of double opt-ins in subscriber attributes.",
     "settings.restart": "重新開始",
     "settings.security.captchaKey": "hCaptcha.com SiteKey",
     "settings.security.captchaKeyHelp": "Visit www.hcaptcha.com to obtain the key and secret.",

+ 6 - 2
internal/core/subscribers.go

@@ -451,8 +451,12 @@ func (c *Core) UnsubscribeByCampaign(subUUID, campUUID string, blocklist bool) e
 }
 
 // ConfirmOptionSubscription confirms a subscriber's optin subscription.
-func (c *Core) ConfirmOptionSubscription(subUUID string, listUUIDs []string) error {
-	if _, err := c.q.ConfirmSubscriptionOptin.Exec(subUUID, pq.Array(listUUIDs)); err != nil {
+func (c *Core) ConfirmOptionSubscription(subUUID string, listUUIDs []string, meta models.JSON) error {
+	if meta == nil {
+		meta = models.JSON{}
+	}
+
+	if _, err := c.q.ConfirmSubscriptionOptin.Exec(subUUID, pq.Array(listUUIDs), meta); err != nil {
 		c.log.Printf("error confirming subscription: %v", err)
 		return echo.NewHTTPError(http.StatusInternalServerError,
 			c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))

+ 5 - 2
internal/migrations/v2.5.0.go

@@ -13,7 +13,8 @@ func V2_5_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
 		INSERT INTO settings (key, value) VALUES
  			('upload.extensions', '["jpg","jpeg","png","gif","svg","*"]'),
  			('app.enable_public_archive_rss_content', 'false'),
- 			('bounce.actions', '{"soft": {"count": 2, "action": "none"}, "hard": {"count": 2, "action": "blocklist"}, "complaint" : {"count": 2, "action": "blocklist"}}')
+ 			('bounce.actions', '{"soft": {"count": 2, "action": "none"}, "hard": {"count": 2, "action": "blocklist"}, "complaint" : {"count": 2, "action": "blocklist"}}'),
+			('privacy.record_optin_ip', 'false')
  			ON CONFLICT DO NOTHING;
 	`); err != nil {
 		return err
@@ -25,12 +26,14 @@ func V2_5_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
 		-- Add the content_type column.
 		ALTER TABLE media ADD COLUMN IF NOT EXISTS content_type TEXT NOT NULL DEFAULT 'application/octet-stream';
 
+		-- Add meta column to subscriptions.
+		ALTER TABLE subscriber_lists ADD COLUMN IF NOT EXISTS meta JSONB NOT NULL DEFAULT '{}';
+
 		-- Fill the content type column for existing files (which would only be images at this point).
 		UPDATE media SET content_type = CASE
 			WHEN LOWER(SUBSTRING(filename FROM '.([^.]+)$')) = 'svg' THEN 'image/svg+xml'
 				ELSE 'image/' || LOWER(SUBSTRING(filename FROM '.([^.]+)$'))
 			END;
-
 	`); err != nil {
 		return err
 	}

+ 3 - 2
models/models.go

@@ -174,8 +174,9 @@ type subLists struct {
 // Subscription represents a list attached to a subscriber.
 type Subscription struct {
 	List
-	SubscriptionStatus    null.String `db:"subscription_status" json:"subscription_status"`
-	SubscriptionCreatedAt null.String `db:"subscription_created_at" json:"subscription_created_at"`
+	SubscriptionStatus    null.String     `db:"subscription_status" json:"subscription_status"`
+	SubscriptionCreatedAt null.String     `db:"subscription_created_at" json:"subscription_created_at"`
+	Meta                  json.RawMessage `db:"meta" json:"meta"`
 }
 
 // SubscriberExportProfile represents a subscriber's collated data in JSON for export.

+ 1 - 0
models/settings.go

@@ -31,6 +31,7 @@ type Settings struct {
 	PrivacyAllowExport        bool     `json:"privacy.allow_export"`
 	PrivacyAllowWipe          bool     `json:"privacy.allow_wipe"`
 	PrivacyExportable         []string `json:"privacy.exportable"`
+	PrivacyRecordOptinIP      bool     `json:"privacy.record_optin_ip"`
 	DomainBlocklist           []string `json:"privacy.domain_blocklist"`
 
 	SecurityEnableCaptcha bool   `json:"security.enable_captcha"`

+ 6 - 2
queries.sql

@@ -43,6 +43,7 @@ WITH subs AS (
                     subscriber_lists.status AS subscription_status,
                     subscriber_lists.created_at AS subscription_created_at,
                     subscriber_lists.updated_at AS subscription_updated_at,
+                    subscriber_lists.meta AS subscription_meta,
                     lists.*
             ) l)
         )
@@ -64,7 +65,10 @@ SELECT id as subscriber_id,
 WITH sub AS (
     SELECT id FROM subscribers WHERE CASE WHEN $1 > 0 THEN id = $1 ELSE uuid = $2 END
 )
-SELECT lists.*, subscriber_lists.status as subscription_status, subscriber_lists.created_at as subscription_created_at
+SELECT lists.*,
+    subscriber_lists.status as subscription_status,
+    subscriber_lists.created_at as subscription_created_at,
+    subscriber_lists.meta as subscription_meta
     FROM lists LEFT JOIN subscriber_lists
     ON (subscriber_lists.list_id = lists.id AND subscriber_lists.subscriber_id = (SELECT id FROM sub))
     WHERE CASE WHEN $3 = TRUE THEN TRUE ELSE subscriber_lists.status IS NOT NULL END
@@ -214,7 +218,7 @@ WITH subID AS (
 listIDs AS (
     SELECT id FROM lists WHERE uuid = ANY($2::UUID[])
 )
-UPDATE subscriber_lists SET status='confirmed', updated_at=NOW()
+UPDATE subscriber_lists SET status='confirmed', meta=meta || $3, updated_at=NOW()
     WHERE subscriber_id = (SELECT id FROM subID) AND list_id = ANY(SELECT id FROM listIDs);
 
 -- name: unsubscribe-subscribers-from-lists

+ 2 - 0
schema.sql

@@ -43,6 +43,7 @@ DROP TABLE IF EXISTS subscriber_lists CASCADE;
 CREATE TABLE subscriber_lists (
     subscriber_id      INTEGER REFERENCES subscribers(id) ON DELETE CASCADE ON UPDATE CASCADE,
     list_id            INTEGER NULL REFERENCES lists(id) ON DELETE CASCADE ON UPDATE CASCADE,
+    meta               JSONB NOT NULL DEFAULT '{}',
     status             subscription_status NOT NULL DEFAULT 'unconfirmed',
 
     created_at         TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
@@ -225,6 +226,7 @@ INSERT INTO settings (key, value) VALUES
     ('privacy.allow_preferences', 'true'),
     ('privacy.exportable', '["profile", "subscriptions", "campaign_views", "link_clicks"]'),
     ('privacy.domain_blocklist', '[]'),
+    ('privacy.record_optin_ip', 'false'),
     ('security.enable_captcha', 'false'),
     ('security.captcha_key', '""'),
     ('security.captcha_secret', '""'),