diff options
Diffstat (limited to 'app/lttr')
37 files changed, 3297 insertions, 0 deletions
diff --git a/app/lttr/__init__.py b/app/lttr/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/app/lttr/__init__.py diff --git a/app/lttr/admin.py b/app/lttr/admin.py new file mode 100644 index 0000000..58f9d84 --- /dev/null +++ b/app/lttr/admin.py @@ -0,0 +1,62 @@ +from django.contrib import admin + +from utils.widgets import AdminImageWidget, LGEntryForm + +from .models import ( + NewsletterMailing, + Subscriber, + Newsletter, + MailingStatus, +) + +@admin.register(Subscriber) +class SubscriberAdmin(admin.ModelAdmin): + list_display = ('email_field', 'user', 'newsletter', 'date_created', 'subscribed', 'subscribe_date', 'unsubscribed') + search_fields = ['email_field'] + list_filter = ['unsubscribed', 'newsletter'] + + class Media: + js = ('next-prev-links.js',) + + +@admin.register(Newsletter) +class NewsletterAdmin(admin.ModelAdmin): + pass + + +@admin.register(MailingStatus) +class MailingStatusAdmin(admin.ModelAdmin): + list_display = ('newsletter_mailing', 'subscriber', 'status', 'creation_date', 'newsletter') + list_filter = ('status', 'creation_date', 'newsletter_mailing__newsletter') + + +@admin.register(NewsletterMailing) +class NewsletterMailingAdmin(admin.ModelAdmin): + form = LGEntryForm + list_display = ('title', 'pub_date', 'newsletter', 'post') + list_filter = ['newsletter'] + fieldsets = ( + ('Entry', { + 'fields': ( + ("newsletter", "post"), + 'title', + 'subtitle', + 'body_markdown', + 'body_html', + 'body_email_html', + 'pub_date', + 'featured_image', + ), + 'classes': ( + 'show', + 'extrapretty', + 'wide' + ) + } + ), + ) + class Media: + js = ('image-loader.js', 'next-prev-links.js') + css = { + "all": ("my_styles.css",) + } diff --git a/app/lttr/forms.py b/app/lttr/forms.py new file mode 100644 index 0000000..e3fb272 --- /dev/null +++ b/app/lttr/forms.py @@ -0,0 +1,100 @@ +from django import forms +from django.forms.utils import ValidationError + +from .validators import validate_email_nouser +from .models import Subscriber + + +class SubscribeForm(forms.ModelForm): + + class Meta: + model = Subscriber + fields = ['user'] + + +class NewsletterForm(forms.ModelForm): + """ This is the base class for all forms managing subscriptions. """ + email_field = forms.EmailField(label='Email Address:', widget=forms.EmailInput(attrs={'placeholder': 'Your email address'})) + + class Meta: + model = Subscriber + fields = ('email_field',) + + def __init__(self, *args, **kwargs): + + assert 'newsletter' in kwargs, 'No newsletter specified' + + newsletter = kwargs.pop('newsletter') + + if 'ip' in kwargs: + ip = kwargs['ip'] + del kwargs['ip'] + else: + ip = None + + super(NewsletterForm, self).__init__(*args, **kwargs) + + self.instance.newsletter = newsletter + + if ip: + self.instance.ip = ip + + +class SubscribeRequestForm(NewsletterForm): + """ + Request subscription to the newsletter. Will result in an activation email + being sent with a link where one can edit, confirm and activate one's + subscription. + """ + email_field = forms.EmailField( + label=("e-mail"), validators=[validate_email_nouser], widget=forms.EmailInput(attrs={'placeholder': 'Your email address'}) + ) + + def clean_email_field(self): + data = self.cleaned_data['email_field'] + + # Check whether we have already been subscribed to + try: + subscription = Subscriber.objects.get( + email_field__exact=data, + newsletter=self.instance.newsletter + ) + + if subscription.subscribed and not subscription.unsubscribed: + raise ValidationError( + "I appreciate the effort, but you're already subscribed. (if you're not receiving newsletters, email me at sng@luxagraf.net and I will see what's going on)" + ) + else: + self.instance = subscription + + self.instance = subscription + + except Subscriber.DoesNotExist: + pass + + return data + + +class UpdateForm(NewsletterForm): + """ + This form allows one to actually update to or unsubscribe from the + newsletter. To do this, a correct activation code is required. + """ + + email_field = forms.EmailField( + label=("e-mail"), validators=[validate_email_nouser], disabled=True + ) + + def clean_user_activation_code(self): + data = self.cleaned_data['user_activation_code'] + + if data != self.instance.activation_code: + raise ValidationError( + ('The validation code supplied by you does not match.') + ) + + return data + + user_activation_code = forms.CharField( + label=("Activation code"), max_length=40 + ) diff --git a/app/lttr/mailer.py b/app/lttr/mailer.py new file mode 100644 index 0000000..40f1003 --- /dev/null +++ b/app/lttr/mailer.py @@ -0,0 +1,83 @@ +from time import sleep +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +from django.core.mail import EmailMultiAlternatives +from django.template.loader import render_to_string +from django.utils.encoding import smart_str + +from .models import Subscriber, MailingStatus + + + +class SendShit(): + + def __init__(self, newsletter, mailing, verbose=0): + self.verbose = verbose + self.newsletter = newsletter + self.mailing = mailing + all_subscribers = Subscriber.objects.filter(newsletter=self.newsletter,subscribed=True,unsubscribed=False).values('email_field') + already_sent = MailingStatus.objects.filter(newsletter_mailing=self.mailing,status=1).values('subscriber__email_field') + self.subscribers = all_subscribers.difference(already_sent) + + + def send_mailings(self): + mailings = len(self.subscribers) + print("mailing newsletter to %s subscribers"%mailings) + i = 1 + for s in self.subscribers: + subscriber = Subscriber.objects.get(newsletter=self.newsletter,email_field=s['email_field'],subscribed=True,unsubscribed=False) + status = None + if self.verbose == 1: + print("mailing newsletter %s of %s to %s" %(i,mailings,subscriber)) + status, created = MailingStatus.objects.get_or_create( + newsletter_mailing=self.mailing, + subscriber=subscriber, + ) + # New instance, try sending + if created: + email = self.build_message(subscriber) + status.status=1 + if self.verbose==1: + print("successfully sent %s the newsletter mailing %s"%(subscriber, self.mailing)) + status.save() + else: + # not new, check if error and resend or just continue + if status.status == 2: + if self.verbose==1: + print("retrying error") + try: + email = self.build_message(subscriber) + status.status=1 + if self.verbose==1: + print("successfully sent %s the newsletter mailing %s"%(subscriber, self.mailing)) + except: + status.status=2 + if self.verbose == 1: + print("failed to send %s to %s"%(self.mailing, subscriber)) + status.save() + i=i+1 + sleep(2) + + + def build_message(self, subscriber): + """ + Build the email as plain text with a + a multipart alternative for HTML + """ + subject = smart_str("%s: %s — %s" %(self.mailing.newsletter.title, self.mailing.get_issue_str(), self.mailing.title)) + from_email, to = 'Scott Gilbertson <sng@luxagraf.net>', subscriber.get_email() + text_content = render_to_string(self.newsletter.get_template_plain(), {'object': self.mailing, 'subscriber':subscriber}) + html_content = render_to_string(self.newsletter.get_template_html(), {'object': self.mailing, 'subscriber':subscriber}) + #print(html_content) + msg = EmailMultiAlternatives(subject, text_content, from_email, [to]) + msg.attach_alternative(html_content, "text/html") + #msg.Header.Add('List-Unsubscribe', '<https://luxagraf.net%s>' % subscriber.unsubscribe_activate_url) + msg.send() + + ''' + for header, value in self.newsletter.server.custom_headers.items(): + message[header] = value + ''' + return msg + diff --git a/app/lttr/management/commands/send_newsletter.py b/app/lttr/management/commands/send_newsletter.py new file mode 100644 index 0000000..0f36183 --- /dev/null +++ b/app/lttr/management/commands/send_newsletter.py @@ -0,0 +1,31 @@ +"""Command for sending the newsletter""" +from django.conf import settings +from django.utils.translation import activate +from django.core.management.base import NoArgsCommand + +from lttr.mailer import Mailer +from lttr.models import NewsletterMailing + + +class Command(NoArgsCommand): + """Send the newsletter in queue""" + help = 'Send the newsletter in queue' + + def handle_noargs(self, **options): + verbose = int(options['verbosity']) + + if verbose: + print('Starting sending newsletters...') + + activate(settings.LANGUAGE_CODE) + + for newsletter in NewsletterMailing.objects.exclude( + status=Newsletter.DRAFT).exclude(status=Newsletter.SENT): + mailer = Mailer(newsletter, verbose=verbose) + if mailer.can_send: + if verbose: + print('Start emailing %s' % newsletter.title) + mailer.run() + + if verbose: + print('End session sending') diff --git a/app/lttr/migrations/0001_initial.py b/app/lttr/migrations/0001_initial.py new file mode 100644 index 0000000..557df91 --- /dev/null +++ b/app/lttr/migrations/0001_initial.py @@ -0,0 +1,78 @@ +# Generated by Django 3.2.8 on 2022-02-04 20:50 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import lttr.models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('posts', '0002_alter_post_post_type'), + ('media', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Newsletter', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=250)), + ('slug', models.SlugField(unique=True)), + ('intro', models.TextField(blank=True, null=True)), + ], + ), + migrations.CreateModel( + name='Subscriber', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email_field', models.EmailField(blank=True, db_column='email', db_index=True, max_length=254, null=True)), + ('date_created', models.DateTimeField(auto_now_add=True)), + ('date_updated', models.DateTimeField(auto_now=True)), + ('activation_code', models.CharField(default=lttr.models.make_activation_code, max_length=40)), + ('subscribed', models.BooleanField(db_index=True, default=False)), + ('subscribe_date', models.DateTimeField(blank=True, null=True)), + ('unsubscribed', models.BooleanField(db_index=True, default=False)), + ('unsubscribe_date', models.DateTimeField(blank=True, null=True)), + ('newsletter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='lttr.newsletter')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='NewsletterMailing', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(blank=True, max_length=250)), + ('subtitle', models.CharField(blank=True, max_length=250, null=True)), + ('body_html', models.TextField(blank=True)), + ('body_email_html', models.TextField(blank=True)), + ('body_markdown', models.TextField(blank=True)), + ('pub_date', models.DateTimeField(blank=True)), + ('featured_image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='media.luximage')), + ('newsletter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='lttr.newsletter')), + ('post', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='posts.post')), + ], + options={ + 'ordering': ('-title', '-pub_date'), + }, + ), + migrations.CreateModel( + name='MailingStatus', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.IntegerField(choices=[(0, 'Initialized'), (1, 'Sent'), (2, 'Error')], null=True)), + ('creation_date', models.DateTimeField(auto_now_add=True, verbose_name='creation date')), + ('newsletter_mailing', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='lttr.newslettermailing', verbose_name='newsletter')), + ('subscriber', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='lttr.subscriber', verbose_name='subscriber')), + ], + options={ + 'verbose_name': 'subscriber mailing status', + 'verbose_name_plural': 'subscriber mailing statuses', + 'ordering': ('-creation_date',), + }, + ), + ] diff --git a/app/lttr/migrations/__init__.py b/app/lttr/migrations/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/app/lttr/migrations/__init__.py diff --git a/app/lttr/models.py b/app/lttr/models.py new file mode 100644 index 0000000..5fed036 --- /dev/null +++ b/app/lttr/models.py @@ -0,0 +1,401 @@ +import datetime + +from django.dispatch import receiver +from django.contrib.gis.db import models +from django.db.models.signals import post_save +from django.contrib.sites.models import Site +from django.template.loader import select_template +from django.utils.translation import ugettext_lazy as _ +from django.utils import timezone +from django.utils.text import slugify +from django.urls import reverse +from django.core.mail import send_mail +from django.conf import settings +from django.utils.crypto import get_random_string +from django.core.mail import EmailMultiAlternatives + +from django.template import Context, Template + +from bs4 import BeautifulSoup + +from taggit.managers import TaggableManager + +from utils.util import render_images, parse_video, markdown_to_html +from taxonomy.models import TaggedItems +from media.models import LuxImage, LuxImageSize +from posts.models import Post + + +# Possible actions that user can perform +ACTIONS = ('subscribe', 'unsubscribe', 'update') + + +def markdown_to_emailhtml(base_html): + soup = BeautifulSoup(base_html, "lxml") + for p in soup.find_all('p'): + p.attrs['style']="margin-top:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;font-weight:normal;margin-bottom:1.4em;font-size:17px;line-height:1.5;hyphens:auto;color:#222222;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif !important;" + for i in soup.find_all('img'): + i.attrs['width']="720" + i.attrs['style']="margin-top:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;display:inline;margin-bottom:0;width:100% !important;max-width:100% !important;height:auto !important;max-height:auto !important;" + for h in soup.find_all('hr'): + h.attrs['style']="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;width:20%;margin-top:40px;margin-bottom:40px;margin-right:0px;margin-left:0px;border-width:0;border-top-width:1px;border-top-style:solid;border-top-color:#ddd;" + return str(soup)[12:-14] + + +def make_activation_code(): + """ Generate a unique activation code. """ + + # Use Django's crypto get_random_string() instead of rolling our own. + return get_random_string(length=40) + + +class Newsletter(models.Model): + """ A model for Newletters. Might I one day have two? I might. """ + title = models.CharField(max_length=250) + slug = models.SlugField(db_index=True, unique=True) + intro = models.TextField(blank=True, null=True) + + def __str__(self): + return self.title + + def get_absolute_url(self): + return reverse("lttr:detail", kwargs={"slug": self.slug}) + + def subscribe_url(self): + return reverse('lttr:newsletter_subscribe_request', kwargs={'newsletter_slug': self.slug}) + + def unsubscribe_url(self): + return reverse('newsletter_unsubscribe_request', kwargs={'newsletter_slug': self.slug}) + + def update_url(self): + return reverse('newsletter_update_request', kwargs={'newsletter_slug': self.slug}) + + def archive_url(self): + return reverse('newsletter_archive', kwargs={'newsletter_slug': self.slug}) + + def get_subscriptions(self): + return Subscriber.objects.filter(newsletter=self, subscribed=True) + + def get_template_plain(self): + return 'lttr/emails/%s_plain_text_email.txt' % self.slug + + def get_template_html(self): + return 'lttr/emails/%s_html_email.html' % self.slug + + def get_templates(self, action): + """ + Return a subject, text, HTML tuple with e-mail templates for + a particular action. Returns a tuple with subject, text and e-mail + template. + """ + + assert action in ACTIONS + ('message', ), 'Unknown action: %s' % action + + # Common substitutions for filenames + tpl_subst = { + 'action': action, + 'newsletter': self.slug + } + + # Common root path for all the templates + tpl_root = 'lttr/message/' + + subject_template = select_template([ + tpl_root + '%(newsletter)s/%(action)s_subject.txt' % tpl_subst, + tpl_root + '%(action)s_subject.txt' % tpl_subst, + ]) + + text_template = select_template([ + tpl_root + '%(newsletter)s/%(action)s.txt' % tpl_subst, + tpl_root + '%(action)s.txt' % tpl_subst, + ]) + + html_template = select_template([ + tpl_root + '%(newsletter)s/%(action)s.html' % tpl_subst, + tpl_root + '%(action)s.html' % tpl_subst, + ]) + + return (subject_template, text_template, html_template) + + def get_sender(self): + return 'Scott Gilbertson <sng@luxagraf.net>' + + @classmethod + def get_default(cls): + try: + return cls.objects.all()[0].pk + except IndexError: + return None + + + +class NewsletterMailing(models.Model): + """ A model for Newletter Mailings, the things actually sent out """ + newsletter = models.ForeignKey(Newsletter, on_delete=models.CASCADE) + post = models.ForeignKey(Post, null=True, blank=True, on_delete=models.SET_NULL) + title = models.CharField(max_length=250, blank=True) + subtitle = models.CharField(max_length=250, null=True, blank=True) + body_html = models.TextField(blank=True) + body_email_html = models.TextField(blank=True) + body_markdown = models.TextField(blank=True) + pub_date = models.DateTimeField(blank=True) + featured_image = models.ForeignKey(LuxImage, on_delete=models.CASCADE, null=True, blank=True) + + class Meta: + ordering = ('-title', '-pub_date') + + def __str__(self): + return self.title + + def email_encode(self): + return self.body_markdown + + def save(self, *args, **kwargs): + created = self.pk is None + if created and self.post: + self.title = self.post.title + self.subtitle = self.post.subtitle + self.body_markdown = self.post.body_markdown + self.pub_date = self.post.pub_date + self.featured_image = self.post.featured_image + self.issue = self.post.issue + if not created: + md = render_images(self.body_markdown) + self.body_html = markdown_to_html(md) + self.body_email_html = markdown_to_emailhtml(self.body_html) + self.date_created = timezone.now() + self.issue = self.post.issue + if created and not self.featured_image: + self.featured_image = LuxImage.objects.latest() + super(NewsletterMailing, self).save() + + +class Subscriber(models.Model): + """ A model for Newletter Subscriber """ + email_field = models.EmailField(db_column='email', db_index=True, blank=True, null=True) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, blank=True, null=True) + date_created = models.DateTimeField(blank=True, auto_now_add=True, editable=False) + date_updated = models.DateTimeField(blank=True, auto_now=True, editable=False) + newsletter = models.ForeignKey(Newsletter, on_delete=models.CASCADE) + activation_code = models.CharField(max_length=40, default=make_activation_code) + subscribed = models.BooleanField(default=False, db_index=True) + subscribe_date = models.DateTimeField(null=True, blank=True) + unsubscribed = models.BooleanField(default=False, db_index=True) + unsubscribe_date = models.DateTimeField(null=True, blank=True) + + def __str__(self): + if self.user: + return self.user.username + return self.email_field + + def get_name(self): + if self.user: + return self.user.get_full_name() + + def get_email(self): + if self.user: + return self.user.email + return self.email_field + + def set_email(self, email): + if not self.user: + self.email_field = email + email = property(get_email, set_email) + + def update(self, action): + """ + Update subscription according to requested action: + subscribe/unsubscribe/update/, then save the changes. + """ + + assert action in ('subscribe', 'update', 'unsubscribe') + + # If a new subscription or update, make sure it is subscribed + # Else, unsubscribe + if action == 'subscribe' or action == 'update': + self.subscribed = True + else: + self.unsubscribed = True + + # This triggers the subscribe() and/or unsubscribe() methods, taking + # care of stuff like maintaining the (un)subscribe date. + self.save() + + def _subscribe(self): + """ + Internal helper method for managing subscription state + during subscription. + """ + + self.subscribe_date = datetime.datetime.now() + self.subscribed = True + self.unsubscribed = False + + def _unsubscribe(self): + """ + Internal helper method for managing subscription state + during unsubscription. + """ + self.subscribed = False + self.unsubscribed = True + self.unsubscribe_date = datetime.datetime.now() + + def save(self, *args, **kwargs): + """ + Perform some basic validation and state maintenance of Subscription. + TODO: Move this code to a more suitable place (i.e. `clean()`) and + cleanup the code. Refer to comment below and + https://docs.djangoproject.com/en/dev/ref/models/instances/#django.db.models.Model.clean + """ + + # This is a lame way to find out if we have changed but using Django + # API internals is bad practice. This is necessary to discriminate + # from a state where we have never been subscribed but is mostly for + # backward compatibility. It might be very useful to make this just + # one attribute 'subscribe' later. In this case unsubscribed can be + # replaced by a method property. + + if self.pk: + assert(Subscriber.objects.filter(pk=self.pk).count() == 1) + + subscription = Subscriber.objects.get(pk=self.pk) + old_subscribed = subscription.subscribed + old_unsubscribed = subscription.unsubscribed + + # If we are subscribed now and we used not to be so, subscribe. + # If we user to be unsubscribed but are not so anymore, subscribe. + if ((self.subscribed and not old_subscribed) or + (old_unsubscribed and not self.unsubscribed)): + self._subscribe() + + assert not self.unsubscribed + assert self.subscribed + + # If we are unsubcribed now and we used not to be so, unsubscribe. + # If we used to be subscribed but are not subscribed anymore, + # unsubscribe. + elif ((self.unsubscribed and not old_unsubscribed) or + (old_subscribed and not self.subscribed)): + self._unsubscribe() + + assert not self.subscribed + assert self.unsubscribed + else: + if self.subscribed: + self._subscribe() + elif self.unsubscribed: + self._unsubscribe() + + super(Subscriber, self).save(*args, **kwargs) + + def get_recipient(self): + return get_address(self.name, self.email) + + def send_activation_email(self, action): + assert action in ACTIONS, 'Unknown action: %s' % action + + (subject_template, text_template, html_template) = \ + self.newsletter.get_templates(action) + + variable_dict = { + 'subscription': self, + 'site': Site.objects.get_current(), + 'newsletter': self.newsletter, + 'date': self.subscribe_date, + 'STATIC_URL': settings.STATIC_URL, + 'MEDIA_URL': settings.MEDIA_URL + } + + subject = subject_template.render(variable_dict).strip() + text = text_template.render(variable_dict) + + message = EmailMultiAlternatives( + subject, text, + from_email=self.newsletter.get_sender(), + to=[self.email] + ) + + if html_template: + message.attach_alternative( + html_template.render(variable_dict), "text/html" + ) + + message.send() + + def subscribe_activate_url(self): + return reverse('lttr:newsletter_activate', kwargs={ + 'slug': self.newsletter.slug, + 'activation_code': self.activation_code + }) + + def unsubscribe_activate_url(self): + return reverse('lttr:newsletter_unsubscribe', kwargs={ + 'slug': self.newsletter.slug, + 'activation_code': self.activation_code + }) + + def update_activate_url(self): + return reverse('lttr:newsletter_update_activate', kwargs={ + 'slug': self.newsletter.slug, + 'action': 'update', + 'activation_code': self.activation_code + }) + + +def get_address(name, email): + if name: + return u'%s <%s>' % (name, email) + else: + return u'%s' % email + + +class StatusType(models.IntegerChoices): + INIT = 0, ('Initialized') + SENT = 1, ('Sent') + ERROR = 2, ('Error') + + +class MailingStatus(models.Model): + newsletter_mailing = models.ForeignKey(NewsletterMailing, on_delete=models.CASCADE, verbose_name=_('newsletter')) + subscriber = models.ForeignKey(Subscriber, on_delete=models.CASCADE, verbose_name=_('subscriber')) + status = models.IntegerField(choices=StatusType.choices, null=True) + creation_date = models.DateTimeField(_('creation date'), auto_now_add=True) + + def newsletter(self): + return self.newsletter_mailing.newsletter + + def __str__(self): + return '%s : %s : %s' % (self.newsletter_mailing, + self.subscriber, + self.get_status_display()) + + class Meta: + ordering = ('-creation_date',) + verbose_name = _('subscriber mailing status') + verbose_name_plural = _('subscriber mailing statuses') + + +''' +from lttr.mailer import SendShit +mailing = NewsletterMailing.objects.get(pk=1) +newsletter = Newsletter.objects.get(pk=3) +n = SendShit(newsletter, mailing, 1) +n.send_mailings() +''' + + +def send_notification_email(newsletter, message, instance): + recipient_list = ['sng@luxagraf.net',] + subject = _('[%(site)s] New Subscriber to "%(object)s"') % { + 'site': Site.objects.get_current().name, + 'object': newsletter, + } + send_mail(subject, message, settings.DEFAULT_FROM_EMAIL, recipient_list, fail_silently=True) + + +@receiver(post_save, sender=Subscriber) +def post_save_events(sender, update_fields, created, instance, **kwargs): + if instance.subscribed: + message = "%s has signed up for %s." %(instance.email_field, instance.newsletter) + send_notification_email(instance.newsletter, message, instance) + diff --git a/app/lttr/modelsnl.py b/app/lttr/modelsnl.py new file mode 100644 index 0000000..9d2a7cd --- /dev/null +++ b/app/lttr/modelsnl.py @@ -0,0 +1,719 @@ +import logging +import time +import django + +from django.conf import settings +from django.contrib.sites.models import Site +from django.contrib.sites.managers import CurrentSiteManager +from django.core.mail import EmailMultiAlternatives +from django.db import models +from django.template.loader import select_template +from django.utils.encoding import python_2_unicode_compatible +from django.utils.functional import cached_property +from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import ugettext +from django.utils.timezone import now + +from sorl.thumbnail import ImageField +from distutils.version import LooseVersion + + +from .compat import get_context, reverse +from .utils import ( + make_activation_code, get_default_sites, ACTIONS +) + +logger = logging.getLogger(__name__) + +AUTH_USER_MODEL = getattr(settings, 'AUTH_USER_MODEL', 'auth.User') + + +@python_2_unicode_compatible +class Newsletter(models.Model): + site = models.ManyToManyField(Site, default=get_default_sites) + + title = models.CharField( + max_length=200, verbose_name=_('newsletter title') + ) + slug = models.SlugField(db_index=True, unique=True) + + email = models.EmailField( + verbose_name=_('e-mail'), help_text=_('Sender e-mail') + ) + sender = models.CharField( + max_length=200, verbose_name=_('sender'), help_text=_('Sender name') + ) + + visible = models.BooleanField( + default=True, verbose_name=_('visible'), db_index=True + ) + + send_html = models.BooleanField( + default=True, verbose_name=_('send html'), + help_text=_('Whether or not to send HTML versions of e-mails.') + ) + + objects = models.Manager() + + # Automatically filter the current site + on_site = CurrentSiteManager() + + def get_templates(self, action): + """ + Return a subject, text, HTML tuple with e-mail templates for + a particular action. Returns a tuple with subject, text and e-mail + template. + """ + + assert action in ACTIONS + ('message', ), 'Unknown action: %s' % action + + # Common substitutions for filenames + tpl_subst = { + 'action': action, + 'newsletter': self.slug + } + + # Common root path for all the templates + tpl_root = 'newsletter/message/' + + subject_template = select_template([ + tpl_root + '%(newsletter)s/%(action)s_subject.txt' % tpl_subst, + tpl_root + '%(action)s_subject.txt' % tpl_subst, + ]) + + text_template = select_template([ + tpl_root + '%(newsletter)s/%(action)s.txt' % tpl_subst, + tpl_root + '%(action)s.txt' % tpl_subst, + ]) + + if self.send_html: + html_template = select_template([ + tpl_root + '%(newsletter)s/%(action)s.html' % tpl_subst, + tpl_root + '%(action)s.html' % tpl_subst, + ]) + else: + # HTML templates are not required + html_template = None + + return (subject_template, text_template, html_template) + + def __str__(self): + return self.title + + class Meta: + verbose_name = _('newsletter') + verbose_name_plural = _('newsletters') + + def get_absolute_url(self): + return reverse('newsletter_detail', kwargs={'newsletter_slug': self.slug}) + + def subscribe_url(self): + return reverse('newsletter_subscribe_request', kwargs={'newsletter_slug': self.slug}) + + def unsubscribe_url(self): + return reverse('newsletter_unsubscribe_request', kwargs={'newsletter_slug': self.slug}) + + def update_url(self): + return reverse('newsletter_update_request', kwargs={'newsletter_slug': self.slug}) + + def archive_url(self): + return reverse('newsletter_archive', kwargs={'newsletter_slug': self.slug}) + + def get_sender(self): + return get_address(self.sender, self.email) + + def get_subscriptions(self): + logger.debug(u'Looking up subscribers for %s', self) + + return Subscription.objects.filter(newsletter=self, subscribed=True) + + @classmethod + def get_default(cls): + try: + return cls.objects.all()[0].pk + except IndexError: + return None + + +@python_2_unicode_compatible +class Subscription(models.Model): + user = models.ForeignKey( + AUTH_USER_MODEL, blank=True, null=True, verbose_name=_('user'), + on_delete=models.CASCADE + ) + + name_field = models.CharField( + db_column='name', max_length=30, blank=True, null=True, + verbose_name=_('name'), help_text=_('optional') + ) + + def get_name(self): + if self.user: + return self.user.get_full_name() + return self.name_field + + def set_name(self, name): + if not self.user: + self.name_field = name + name = property(get_name, set_name) + + email_field = models.EmailField( + db_column='email', verbose_name=_('e-mail'), db_index=True, + blank=True, null=True + ) + + def get_email(self): + if self.user: + return self.user.email + return self.email_field + + def set_email(self, email): + if not self.user: + self.email_field = email + email = property(get_email, set_email) + + def update(self, action): + """ + Update subscription according to requested action: + subscribe/unsubscribe/update/, then save the changes. + """ + + assert action in ('subscribe', 'update', 'unsubscribe') + + # If a new subscription or update, make sure it is subscribed + # Else, unsubscribe + if action == 'subscribe' or action == 'update': + self.subscribed = True + else: + self.unsubscribed = True + + logger.debug( + _(u'Updated subscription %(subscription)s to %(action)s.'), + { + 'subscription': self, + 'action': action + } + ) + + # This triggers the subscribe() and/or unsubscribe() methods, taking + # care of stuff like maintaining the (un)subscribe date. + self.save() + + def _subscribe(self): + """ + Internal helper method for managing subscription state + during subscription. + """ + logger.debug(u'Subscribing subscription %s.', self) + + self.subscribe_date = now() + self.subscribed = True + self.unsubscribed = False + + def _unsubscribe(self): + """ + Internal helper method for managing subscription state + during unsubscription. + """ + logger.debug(u'Unsubscribing subscription %s.', self) + + self.subscribed = False + self.unsubscribed = True + self.unsubscribe_date = now() + + def save(self, *args, **kwargs): + """ + Perform some basic validation and state maintenance of Subscription. + TODO: Move this code to a more suitable place (i.e. `clean()`) and + cleanup the code. Refer to comment below and + https://docs.djangoproject.com/en/dev/ref/models/instances/#django.db.models.Model.clean + """ + assert self.user or self.email_field, \ + _('Neither an email nor a username is set. This asks for ' + 'inconsistency!') + assert ((self.user and not self.email_field) or + (self.email_field and not self.user)), \ + _('If user is set, email must be null and vice versa.') + + # This is a lame way to find out if we have changed but using Django + # API internals is bad practice. This is necessary to discriminate + # from a state where we have never been subscribed but is mostly for + # backward compatibility. It might be very useful to make this just + # one attribute 'subscribe' later. In this case unsubscribed can be + # replaced by a method property. + + if self.pk: + assert(Subscription.objects.filter(pk=self.pk).count() == 1) + + subscription = Subscription.objects.get(pk=self.pk) + old_subscribed = subscription.subscribed + old_unsubscribed = subscription.unsubscribed + + # If we are subscribed now and we used not to be so, subscribe. + # If we user to be unsubscribed but are not so anymore, subscribe. + if ((self.subscribed and not old_subscribed) or + (old_unsubscribed and not self.unsubscribed)): + self._subscribe() + + assert not self.unsubscribed + assert self.subscribed + + # If we are unsubcribed now and we used not to be so, unsubscribe. + # If we used to be subscribed but are not subscribed anymore, + # unsubscribe. + elif ((self.unsubscribed and not old_unsubscribed) or + (old_subscribed and not self.subscribed)): + self._unsubscribe() + + assert not self.subscribed + assert self.unsubscribed + else: + if self.subscribed: + self._subscribe() + elif self.unsubscribed: + self._unsubscribe() + + super(Subscription, self).save(*args, **kwargs) + + ip = models.GenericIPAddressField(_("IP address"), blank=True, null=True) + + newsletter = models.ForeignKey( + Newsletter, verbose_name=_('newsletter'), on_delete=models.CASCADE + ) + + create_date = models.DateTimeField(editable=False, default=now) + + activation_code = models.CharField( + verbose_name=_('activation code'), max_length=40, + default=make_activation_code + ) + + subscribed = models.BooleanField( + default=False, verbose_name=_('subscribed'), db_index=True + ) + subscribe_date = models.DateTimeField( + verbose_name=_("subscribe date"), null=True, blank=True + ) + + # This should be a pseudo-field, I reckon. + unsubscribed = models.BooleanField( + default=False, verbose_name=_('unsubscribed'), db_index=True + ) + unsubscribe_date = models.DateTimeField( + verbose_name=_("unsubscribe date"), null=True, blank=True + ) + + def __str__(self): + if self.name: + return _(u"%(name)s <%(email)s> to %(newsletter)s") % { + 'name': self.name, + 'email': self.email, + 'newsletter': self.newsletter + } + + else: + return _(u"%(email)s to %(newsletter)s") % { + 'email': self.email, + 'newsletter': self.newsletter + } + + class Meta: + verbose_name = _('subscription') + verbose_name_plural = _('subscriptions') + unique_together = ('user', 'email_field', 'newsletter') + + def get_recipient(self): + return get_address(self.name, self.email) + + def send_activation_email(self, action): + assert action in ACTIONS, 'Unknown action: %s' % action + + (subject_template, text_template, html_template) = \ + self.newsletter.get_templates(action) + + variable_dict = { + 'subscription': self, + 'site': Site.objects.get_current(), + 'newsletter': self.newsletter, + 'date': self.subscribe_date, + 'STATIC_URL': settings.STATIC_URL, + 'MEDIA_URL': settings.MEDIA_URL + } + + unescaped_context = get_context(variable_dict, autoescape=False) + + subject = subject_template.render(unescaped_context).strip() + text = text_template.render(unescaped_context) + + message = EmailMultiAlternatives( + subject, text, + from_email=self.newsletter.get_sender(), + to=[self.email] + ) + + if html_template: + escaped_context = get_context(variable_dict) + + message.attach_alternative( + html_template.render(escaped_context), "text/html" + ) + + message.send() + + logger.debug( + u'Activation email sent for action "%(action)s" to %(subscriber)s ' + u'with activation code "%(action_code)s".', { + 'action_code': self.activation_code, + 'action': action, + 'subscriber': self + } + ) + + def subscribe_activate_url(self): + return reverse('newsletter_update_activate', kwargs={ + 'newsletter_slug': self.newsletter.slug, + 'email': self.email, + 'action': 'subscribe', + 'activation_code': self.activation_code + }) + + def unsubscribe_activate_url(self): + return reverse('newsletter_update_activate', kwargs={ + 'newsletter_slug': self.newsletter.slug, + 'email': self.email, + 'action': 'unsubscribe', + 'activation_code': self.activation_code + }) + + def update_activate_url(self): + return reverse('newsletter_update_activate', kwargs={ + 'newsletter_slug': self.newsletter.slug, + 'email': self.email, + 'action': 'update', + 'activation_code': self.activation_code + }) + + +@python_2_unicode_compatible +class Article(models.Model): + """ + An Article within a Message which will be send through a Submission. + """ + + sortorder = models.PositiveIntegerField( + help_text=_('Sort order determines the order in which articles are ' + 'concatenated in a post.'), + verbose_name=_('sort order'), blank=True + ) + + title = models.CharField(max_length=200, verbose_name=_('title')) + text = models.TextField(verbose_name=_('text')) + + url = models.URLField( + verbose_name=_('link'), blank=True, null=True + ) + + # Make this a foreign key for added elegance + image = ImageField( + upload_to='newsletter/images/%Y/%m/%d', blank=True, null=True, + verbose_name=_('image') + ) + + # Message this article is associated with + # TODO: Refactor post to message (post is legacy notation). + post = models.ForeignKey( + 'Message', verbose_name=_('message'), related_name='articles', + on_delete=models.CASCADE + ) + + class Meta: + ordering = ('sortorder',) + verbose_name = _('article') + verbose_name_plural = _('articles') + unique_together = ('post', 'sortorder') + + def __str__(self): + return self.title + + def save(self, **kwargs): + if self.sortorder is None: + # If saving a new object get the next available Article ordering + # as to assure uniqueness. + self.sortorder = self.post.get_next_article_sortorder() + + super(Article, self).save() + + +def get_default_newsletter(): + return Newsletter.get_default() + +@python_2_unicode_compatible +class Message(models.Model): + """ Message as sent through a Submission. """ + + title = models.CharField(max_length=200, verbose_name=_('title')) + slug = models.SlugField(verbose_name=_('slug')) + + newsletter = models.ForeignKey( + Newsletter, verbose_name=_('newsletter'), on_delete=models.CASCADE, default=get_default_newsletter + ) + + date_create = models.DateTimeField( + verbose_name=_('created'), auto_now_add=True, editable=False + ) + date_modify = models.DateTimeField( + verbose_name=_('modified'), auto_now=True, editable=False + ) + + class Meta: + verbose_name = _('message') + verbose_name_plural = _('messages') + unique_together = ('slug', 'newsletter') + + def __str__(self): + try: + return _(u"%(title)s in %(newsletter)s") % { + 'title': self.title, + 'newsletter': self.newsletter + } + except Newsletter.DoesNotExist: + logger.warning('No newsletter has been set for this message yet.') + return self.title + + def get_next_article_sortorder(self): + """ Get next available sortorder for Article. """ + + next_order = self.articles.aggregate( + models.Max('sortorder') + )['sortorder__max'] + + if next_order: + return next_order + 10 + else: + return 10 + + @cached_property + def _templates(self): + """Return a (subject_template, text_template, html_template) tuple.""" + return self.newsletter.get_templates('message') + + @property + def subject_template(self): + return self._templates[0] + + @property + def text_template(self): + return self._templates[1] + + @property + def html_template(self): + return self._templates[2] + + @classmethod + def get_default(cls): + try: + return cls.objects.order_by('-date_create').all()[0] + except IndexError: + return None + + +@python_2_unicode_compatible +class Submission(models.Model): + """ + Submission represents a particular Message as it is being submitted + to a list of Subscribers. This is where actual queueing and submission + happen. + """ + class Meta: + verbose_name = _('submission') + verbose_name_plural = _('submissions') + + def __str__(self): + return _(u"%(newsletter)s on %(publish_date)s") % { + 'newsletter': self.message, + 'publish_date': self.publish_date + } + + @cached_property + def extra_headers(self): + return { + 'List-Unsubscribe': 'http://%s%s' % ( + Site.objects.get_current().domain, + reverse('newsletter_unsubscribe_request', + args=[self.message.newsletter.slug]) + ), + } + + def submit(self): + subscriptions = self.subscriptions.filter(subscribed=True) + + logger.info( + ugettext(u"Submitting %(submission)s to %(count)d people"), + {'submission': self, 'count': subscriptions.count()} + ) + + assert self.publish_date < now(), \ + 'Something smells fishy; submission time in future.' + + self.sending = True + self.save() + + try: + for idx, subscription in enumerate(subscriptions, start=1): + if hasattr(settings, 'NEWSLETTER_EMAIL_DELAY'): + time.sleep(settings.NEWSLETTER_EMAIL_DELAY) + if hasattr(settings, 'NEWSLETTER_BATCH_SIZE') and settings.NEWSLETTER_BATCH_SIZE > 0: + if idx % settings.NEWSLETTER_BATCH_SIZE == 0: + time.sleep(settings.NEWSLETTER_BATCH_DELAY) + self.send_message(subscription) + self.sent = True + + finally: + self.sending = False + self.save() + + def send_message(self, subscription): + variable_dict = { + 'subscription': subscription, + 'site': Site.objects.get_current(), + 'submission': self, + 'message': self.message, + 'newsletter': self.newsletter, + 'date': self.publish_date, + 'STATIC_URL': settings.STATIC_URL, + 'MEDIA_URL': settings.MEDIA_URL + } + + unescaped_context = get_context(variable_dict, autoescape=False) + + subject = self.message.subject_template.render( + unescaped_context).strip() + text = self.message.text_template.render(unescaped_context) + + message = EmailMultiAlternatives( + subject, text, + from_email=self.newsletter.get_sender(), + to=[subscription.get_recipient()], + headers=self.extra_headers, + ) + + if self.message.html_template: + escaped_context = get_context(variable_dict) + + message.attach_alternative( + self.message.html_template.render(escaped_context), + "text/html" + ) + + try: + logger.debug( + ugettext(u'Submitting message to: %s.'), + subscription + ) + + message.send() + + except Exception as e: + # TODO: Test coverage for this branch. + logger.error( + ugettext(u'Message %(subscription)s failed ' + u'with error: %(error)s'), + {'subscription': subscription, + 'error': e} + ) + + @classmethod + def submit_queue(cls): + todo = cls.objects.filter( + prepared=True, sent=False, sending=False, + publish_date__lt=now() + ) + + for submission in todo: + submission.submit() + + @classmethod + def from_message(cls, message): + logger.debug(ugettext('Submission of message %s'), message) + submission = cls() + submission.message = message + submission.newsletter = message.newsletter + submission.save() + try: + submission.subscriptions.set(message.newsletter.get_subscriptions()) + except AttributeError: # Django < 1.10 + submission.subscriptions = message.newsletter.get_subscriptions() + return submission + + def save(self, **kwargs): + """ Set the newsletter from associated message upon saving. """ + assert self.message.newsletter + + self.newsletter = self.message.newsletter + + return super(Submission, self).save() + + + + def get_absolute_url(self): + assert self.newsletter.slug + assert self.message.slug + + return reverse( + 'newsletter_archive_detail', kwargs={ + 'newsletter_slug': self.newsletter.slug, + 'year': self.publish_date.year, + 'month': self.publish_date.month, + 'day': self.publish_date.day, + 'slug': self.message.slug + } + ) + + newsletter = models.ForeignKey( + Newsletter, verbose_name=_('newsletter'), editable=False, + on_delete=models.CASCADE + ) + message = models.ForeignKey( + Message, verbose_name=_('message'), editable=True, null=False, + on_delete=models.CASCADE + ) + + subscriptions = models.ManyToManyField( + 'Subscription', + help_text=_('If you select none, the system will automatically find ' + 'the subscribers for you.'), + blank=True, db_index=True, verbose_name=_('recipients'), + limit_choices_to={'subscribed': True} + ) + + publish_date = models.DateTimeField( + verbose_name=_('publication date'), blank=True, null=True, + default=now, db_index=True + ) + publish = models.BooleanField( + default=True, verbose_name=_('publish'), + help_text=_('Publish in archive.'), db_index=True + ) + + prepared = models.BooleanField( + default=False, verbose_name=_('prepared'), + db_index=True, editable=False + ) + sent = models.BooleanField( + default=False, verbose_name=_('sent'), + db_index=True, editable=False + ) + sending = models.BooleanField( + default=False, verbose_name=_('sending'), + db_index=True, editable=False + ) + +def get_address(name, email): + # Converting name to ascii for compatibility with django < 1.9. + # Remove this when django 1.8 is no longer supported. + if LooseVersion(django.get_version()) < LooseVersion('1.9'): + name = name.encode('ascii', 'ignore').decode('ascii').strip() + if name: + return u'%s <%s>' % (name, email) + else: + return u'%s' % email diff --git a/app/lttr/send.py b/app/lttr/send.py new file mode 100644 index 0000000..cd012e2 --- /dev/null +++ b/app/lttr/send.py @@ -0,0 +1,6 @@ +from lttr.models import Newsletter, NewsletterMailing +from lttr.mailer import SendShit +n = Newsletter.objects.get(pk=tk) +m = NewsletterMailing.objects.get(pk=tk) +s = SendShit(n, m) +s.send_mailings() diff --git a/app/lttr/templates/lttr/confirm_activate.html b/app/lttr/templates/lttr/confirm_activate.html new file mode 100644 index 0000000..a2d3a3d --- /dev/null +++ b/app/lttr/templates/lttr/confirm_activate.html @@ -0,0 +1,24 @@ +{% extends 'base.html' %} +{% load typogrify_tags %} + +{% block pagetitle %}Your subscription is active, thank you! | luxagraf.net {% endblock %} +{% block metadescription %}Thank you, I appreciate you joining the club{% endblock %} + +{% block primary %} + <nav class="breadcrumbs" itemscope itemtype="http://schema.org/BreadcrumbList"> + <span class="nav-item" itemprop="item"> + <a href="/" itemprop="name">Home</a> + <meta itemprop="position" content="1" /> + </span> + <span class="nav-item" itemprop="item"> + <span itemprop="name">lttr</span> + <meta itemprop="position" content="2" /> + </span> + </nav> + <main role="main" id="essay-archive" class="archive-wrapper"> + <div class="archive-intro"> + <h2>You're confirmed, thanks for joining.</h2> + <p>If you'd like you can <a href="/{{newsletter}}/">browse the archives</a> of past mailings.</p> + </div> + </main> +{%endblock%} diff --git a/app/lttr/templates/lttr/emails/friends_html_email.html b/app/lttr/templates/lttr/emails/friends_html_email.html new file mode 100644 index 0000000..b7e4bf2 --- /dev/null +++ b/app/lttr/templates/lttr/emails/friends_html_email.html @@ -0,0 +1,249 @@ +{% load typogrify_tags %} +<!DOCTYPE html> +<html lang="en" style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" > +<head style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" > + <meta name="viewport" content="width=device-width" style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" /> + <meta name=”robot” content=”noindex” style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" > + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" /> + <title style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >{{object.title}}</title> + + <style type="text/css" style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" > +@font-face { + font-family: 'mffnweb'; + src: url('https://luxagraf.net/media/fonts/ffmn.woff2') format('woff2'); + src: url('https://luxagraf.net/media/fonts/ffmn.woff') format('woff'); + font-weight: 400; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: 'mffnbweb'; + src: url('https://luxagraf.net/media/fonts/ffmn.woff2') format('woff2'); + src: url('https://luxagraf.net/media/fonts/ffmn.woff') format('woff'); + font-weight: 700; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: 'mffweb'; + src: url('https://luxagraf.net/media/fonts/ffmpb.woff2') format('woff2'); + src: url('https://luxagraf.net/media/fonts/ffmpb.woff') format('woff'); + font-weight: 400; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: 'mffweb'; + src: url('https://luxagraf.net/media/fonts/ffmbi.woff2') format('woff2'); + src: url('https://luxagraf.net/media/fonts/ffmbi.woff') format('woff'); + font-weight: 400; + font-style: italic; + font-display: swap; +} + +.tk-ff-meta-web-pro { font-family: mffnbweb,sans-serif; } +.tk-mffnweb { font-family: mffnweb,serif; } +* { + margin:0; + padding:0; +} +* { } +sup, sub { + vertical-align: baseline; + position: relative; + top: -0.4em; +} +sub { + top: 0.4em; +} +img { + max-width: 100%; +} +img.fullbleed { display: inline; border-radius: 3px; margin-bottom: 1.5em; width: 100% !important; max-width: 100% !important; height: auto !important; max-height: auto !important; } +p img.fullbleed { margin-bottom: 0px; } +.collapse { + margin:0; + padding:0; +} +body { + -webkit-font-smoothing:antialiased; + -webkit-text-size-adjust:none; + width: 100%!important; + background-color: #ffffff; + height: 100%; + font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif; +} +a { color: #000; font-weight: 600; text-decoration: none; border-bottom: 1px solid #ddd; } +.btn { + text-decoration:none; + color: #FFF; + background-color: #666; + padding:10px 16px; + font-weight:bold; + margin-right:10px; + text-align:center; + cursor:pointer; + display: inline-block; +} +p.callout { + padding:15px; + background-color:#ECF8FF; + margin-bottom: 15px; +} +.callout a { + font-weight:bold; + color: #2BA6CB; +} +.highlight { background-color: #ffffb2;} +figure { border-top: 1px solid #ddd; border-bottom: 1px solid #ddd; padding-top: 20px; padding-bottom: 20px; margin-bottom: 30px; } +figcaption { text-align: center; font-size: .8em; } +.sp { font-size: .85em; text-transform: uppercase; font-weight: bold; letter-spacing: 1px; } +table.head-wrap { width: 100%;} +table.body-wrap { width: 100%;} +table.footer-wrap { + width: 100%; + clear:both!important; + color: #999; + font-family: helvetica !important; + font-size: 10px !important; +} +.footer-wrap .container .content p { + font-size: 14px; +} +.footer-wrap .container .content a { color: #333; text-decoration: none; } +.footnotes ol li { font-size: .8em; } +.footnotes ol li p { font-size: .8em; } +.footnotes hr { display: none; } +h1,h2,h3,h4,h5,h6 { +font-family: mffnweb, 'Lora', 'Lucida Serif', Lucida, Georgia, serif; +line-height: 1; +margin-bottom:15px; +color:#000; +text-align: center; +} +h1 small, h2 small, h3 small, h4 small, h5 small, h6 small { font-size: 60%; color: #6f6f6f; line-height: 0; text-transform: none; } +h1 { font-weight:400; font-size: 44px;} +h2 { font-weight:400; font-size: 30px;} +h4 { font-weight:500; font-size: 23px;} +h5 { font-weight:500; font-size: 23px; font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif; text-align: left; } +h3, h6 { font-weight:400; font-size: 32px; font-style: italic; margin-top: 40px; text-transform: none; color:#000; text-align: left;} +h2 { font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif; } +h2 a { font-weight: normal; } +.collapse { margin:0!important;} +p, ul, ol { + margin-bottom: 1.4em; + font-weight: 400; + + font-size:17px; + line-height:1.5; + hyphens: auto; +} +hr { width: 50%; margin: 40px auto; border: 0; border-top: 1px solid #ddd; } +p.quote { padding-left: 10px; border-left: 2px solid #ddd; } +blockquote { border-left: 4px solid #efefef; padding-left: 15px; font-style: italic; } +p.lead { font-size:17px; } +p.last { margin-bottom:0px; } +ul li, ol li { + margin-left: 35px; + list-style-position: outside; +} +.container { + display:block!important; + max-width:720px!important; + margin:0 auto!important; + clear:both!important; +} +.content { + padding:17px; + max-width:720px; + margin:0 auto; + display:block; +} +.content table { width: 100%; } +.clear { display: block; clear: both; } +@media only screen and (max-width: 700px) { + + a[class="btn"] { display:block!important; margin-bottom:10px!important; background-image:none!important; margin-right:0!important;} + img.fullbleed { margin-bottom: 1.5em; width: 100%; height: auto !important; } + p { font-size: 16px;} + h1 { font-size: 36px; } + div[class="column"] { width: auto!important; float:none!important;} + + table.social div[class="column"] { + width:auto!important; + } +} + +</style> + +</head> +<body style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;-webkit-font-smoothing:antialiased;-webkit-text-size-adjust:none;width:100%!important;background-color:#ffffff;height:100%;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif;" > + <table class="body-wrap" style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;width:100%;" > + <tr style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" > + <td style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" ></td> + <td class="container" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;display:block!important;max-width:720px!important;margin-top:0 !important;margin-bottom:0 !important;margin-right:auto !important;margin-left:auto !important;clear:both!important;" > + <div class="content" style="padding-top:15px;padding-bottom:15px;padding-right:15px;padding-left:15px;max-width:720px;margin-top:0;margin-bottom:0;margin-right:auto;margin-left:auto;display:block;" > + <table style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;width:100%;" > + <tr style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" > + <td style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" > + <h2 style="margin-top:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;line-height:1;margin-bottom:15px;color:#000;text-align:center;font-weight:400;font-size:30px;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif;" ><br style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" /> + <span style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;font-size:.5em;line-height:2em;" ><singleline style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" ><a href="https://luxagraf.net/friends/" style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;color:#000;text-decoration:none;border-bottom-width:1px;border-bottom-style:solid;border-bottom-color:#ddd;font-weight:normal;" >Friends of a Long Year</a> — {{object.get_issue_str}} — {{object.pub_date|date:"F"}} <span>{{object.pub_date|date:"j, Y"}}</span></singleline></span></h2> + <h1 style="margin-top:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;font-family:mffnweb, 'Lora', 'Lucida Serif', Lucida, Georgia, serif;line-height:1;margin-bottom:15px;color:#000;text-align:center;font-weight:400;font-size:44px;" ><singleline style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >{{object.title|safe|smartypants}}</singleline></h1> + + <hr style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;width:50%;margin-top:40px;margin-bottom:40px;margin-right:auto;margin-left:auto;border-width:0;border-top-width:1px;border-top-style:solid;border-top-color:#ddd;" /> + <a href="https://luxagraf.net{{object.post.get_absolute_url}}" style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;border-width:0;color:#000;font-weight:600;text-decoration:none;border-bottom-width:1px;border-bottom-style:solid;border-bottom-color:#ddd;" > + {% include "lib/friends_featured_img.html" with image=object.featured_image %} + </a> + <br style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" /> + <br style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" /> + + {{object.body_email_html|safe|smartypants}} + + <hr style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;width:50%;margin-top:40px;margin-bottom:40px;margin-right:auto;margin-left:auto;border-width:0;border-top-width:1px;border-top-style:solid;border-top-color:#ddd;" /> + </td> + </tr> + </table> + </div> +</td> +<td style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" ></td> +</tr> +</table> +<table class="footer-wrap" style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;width:100%;clear:both!important;color:#999;font-family:helvetica !important;font-size:10px !important;" > + <tr style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" > + <td style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" ></td> + <td class="container" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;display:block!important;max-width:720px!important;margin-top:0 !important;margin-bottom:0 !important;margin-right:auto !important;margin-left:auto !important;clear:both!important;" > + + + <div class="content" style="padding-top:15px;padding-bottom:15px;padding-right:15px;padding-left:15px;max-width:720px;margin-top:0;margin-bottom:0;margin-right:auto;margin-left:auto;display:block;" > + <table style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;width:100%;" > + <tr style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" > + <td align="center" style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" > + <p style="margin-top:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;margin-bottom:1.4em;font-weight:400;line-height:1.5;hyphens:auto;font-size:14px;" >New subscriber?<br style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" /> + + Browse the <a href="https://luxagraf.net/jrnl/" style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;font-weight:600;border-bottom-width:1px;border-bottom-style:solid;border-bottom-color:#ddd;color:#333;text-decoration:none;" >online archives</a> here.</p> + <p style="margin-top:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;margin-bottom:1.4em;font-weight:400;line-height:1.5;hyphens:auto;font-size:14px;" >⫹⫺</p> + <p style="margin-top:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;margin-bottom:1.4em;font-weight:400;line-height:1.5;hyphens:auto;font-size:14px;" ><em style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >Friends</em>?<br style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" /> + + A monthly letter from <a href="https://luxagraf.net/" style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;font-weight:600;border-bottom-width:1px;border-bottom-style:solid;border-bottom-color:#ddd;color:#333;text-decoration:none;" >Scott Gilberson</a>, <br style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" />also known as luxagraf..<br style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" /></p> + + <p style="margin-top:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;margin-bottom:1.4em;font-weight:400;line-height:1.5;hyphens:auto;font-size:14px;" >⫹⫺</p> +<p style="margin-top:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;margin-bottom:1.4em;font-weight:400;line-height:1.5;hyphens:auto;font-size:14px;" > + Shipped from Points Unknown, USA.<br style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" /> + Explained <a href="https://luxagraf.net/jrnl/2020/11/invitation" style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;font-weight:600;border-bottom-width:1px;border-bottom-style:solid;border-bottom-color:#ddd;color:#333;text-decoration:none;" >here</a>.</p> + <br style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" /> + <p style="margin-top:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;margin-bottom:1.4em;font-weight:400;line-height:1.5;hyphens:auto;font-size:14px;" >If you enjoy this, <br style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" /> + please consider forwarding it to a friend. <br style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" />We are after all, friends of a long year here.</p> + <p style="margin-top:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;margin-bottom:1.4em;font-weight:400;line-height:1.5;hyphens:auto;font-size:14px;" >⫹⫺</p> + <p style="margin-top:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;font-weight:normal;margin-bottom:1.4em;line-height:1.5;hyphens:auto;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif !important;color:#999;font-size:14px;" >You can always: <a href="https://luxagraf.net{{subscriber.unsubscribe_activate_url}}" style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;font-weight:600;text-decoration:underline;color:#999 !important;" >Unsubscribe</a> instantly.</p> + </td> + </tr> + </table> + </div> + + </td> + <td style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" ></td> + </tr> +</table> +</html> + + diff --git a/app/lttr/templates/lttr/emails/friends_plain_text_email.txt b/app/lttr/templates/lttr/emails/friends_plain_text_email.txt new file mode 100644 index 0000000..9ff7f61 --- /dev/null +++ b/app/lttr/templates/lttr/emails/friends_plain_text_email.txt @@ -0,0 +1,16 @@ + +{{ object.email_encode|safe }} + +----- + +You're getting this email because you signed up for + +Scott Gilbertson's (luxagraf)[https://luxagraf.net/] newsletter, + +*Friends of a Long Year* [https://luxagraf.net/friends/] + +If you're new, you can explore past posts here: [https://luxagraf.net/jrnl/] + +You can always: Unsubscribe [https://luxagraf.net{{subscriber.unsubscribe_activate_url}}] instantly. + +[https://luxagraf.net/] ✪ [https://luxagraf.net/friends/] diff --git a/app/lttr/templates/lttr/emails/range_html_email.html b/app/lttr/templates/lttr/emails/range_html_email.html new file mode 100644 index 0000000..09c2db3 --- /dev/null +++ b/app/lttr/templates/lttr/emails/range_html_email.html @@ -0,0 +1,239 @@ +{% load typogrify_tags %} +<!DOCTYPE html> +<html lang="en" style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" > +<head style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" > + <meta name="viewport" content="width=device-width" style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" /> + <meta name=”robot” content=”noindex” style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" > + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" /> + <title style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >{{object.title}}</title> + + <style type="text/css" style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" > +@font-face { + font-family: 'mffnweb'; + src: url('https://luxagraf.net/media/fonts/ffmn.woff2') format('woff2'); + src: url('https://luxagraf.net/media/fonts/ffmn.woff') format('woff'); + font-weight: 400; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: 'mffnbweb'; + src: url('https://luxagraf.net/media/fonts/ffmn.woff2') format('woff2'); + src: url('https://luxagraf.net/media/fonts/ffmn.woff') format('woff'); + font-weight: 700; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: 'mffweb'; + src: url('https://luxagraf.net/media/fonts/ffmpb.woff2') format('woff2'); + src: url('https://luxagraf.net/media/fonts/ffmpb.woff') format('woff'); + font-weight: 400; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: 'mffweb'; + src: url('https://luxagraf.net/media/fonts/ffmbi.woff2') format('woff2'); + src: url('https://luxagraf.net/media/fonts/ffmbi.woff') format('woff'); + font-weight: 400; + font-style: italic; + font-display: swap; +} + +.tk-ff-meta-web-pro { font-family: mffnbweb,sans-serif; } +.tk-mffnweb { font-family: mffnweb,serif; } +* { + margin:0; + padding:0; +} +* { } +sup, sub { + vertical-align: baseline; + position: relative; + top: -0.4em; +} +sub { + top: 0.4em; +} +img { + width: 846px; +} +p img.fullbleed { margin-bottom: 0px; } +.collapse { + margin:0; + padding:0; +} +body { + -webkit-font-smoothing:antialiased; + -webkit-text-size-adjust:none; + width: 100%!important; + background-color: #ffffff; + height: 100%; + font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif; +} +a { color: #000; font-weight: 600; text-decoration: none; border-bottom: 1px solid #ddd; } +.btn { + text-decoration:none; + color: #FFF; + background-color: #666; + padding:10px 16px; + font-weight:bold; + margin-right:10px; + text-align:center; + cursor:pointer; + display: inline-block; +} +p.callout { + padding:15px; + background-color:#ECF8FF; + margin-bottom: 15px; +} +.callout a { + font-weight:bold; + color: #2BA6CB; +} +.highlight { background-color: #ffffb2;} +figure { border-top: 1px solid #ddd; border-bottom: 1px solid #ddd; padding-top: 20px; padding-bottom: 20px; margin-bottom: 30px; } +figcaption { text-align: center; font-size: .8em; } +table.head-wrap { width: 100%;} +table.body-wrap { width: 100%;} +table.footer-wrap { + width: 100%; + clear:both!important; + color: #999; + font-family: helvetica !important; + font-size: 10px !important; +} +.footer-wrap .container .content p { + font-size: 14px; +} +.footer-wrap .container .content a { color: #333; text-decoration: none; } +.footnotes ol li { font-size: .8em; } +.footnotes ol li p { font-size: .8em; } +.footnotes hr { display: none; } +h1,h2,h3,h4,h5,h6 { +font-family: mffnweb, 'Lucida Serif', Georgia, serif; +line-height: 1; +margin-bottom:15px; +color:#000; +text-align: center; +} +h1 small, h2 small, h3 small, h4 small, h5 small, h6 small { font-size: 60%; color: #6f6f6f; line-height: 0; text-transform: none; } +h1 { font-weight:400; font-size: 44px;} +h2 { font-weight:400; font-size: 30px;} +h4 { font-weight:500; font-size: 23px;} +h5 { font-weight:500; font-size: 23px; font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif; text-align: left; } +h3, h6 { font-weight:400; font-size: 32px; font-style: italic; margin-top: 40px; text-transform: none; color:#000; text-align: left;} +h2 { font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif; } +h2 a { font-weight: normal; } +.collapse { margin:0!important;} +p, ul, ol { + margin-bottom: 1.4em; + font-weight: 400; + + font-size:17px; + line-height:1.5; + hyphens: auto; +} +hr { width: 50%; margin: 40px auto; border: 0; border-top: 1px solid #ddd; } +p.quote { padding-left: 10px; border-left: 2px solid #ddd; } +blockquote { border-left: 4px solid #efefef; padding-left: 15px; font-style: italic; } +ul li, ol li { + margin-left: 35px; + list-style-position: outside; +} +.container { + display:block!important; + max-width:960px!important; + margin:0 auto!important; + clear:both!important; +} +.content { + padding:17px; + max-width:960px; + margin:0 auto; + display:block; +} +.content table { width: 100%; } +.clear { display: block; clear: both; } +@media only screen and (max-width: 700px) { + + a[class="btn"] { display:block!important; margin-bottom:10px!important; background-image:none!important; margin-right:0!important;} + p { font-size: 16px;} + h1 { font-size: 36px; } + div[class="column"] { width: auto!important; float:none!important;} + +} + +</style> + +</head> +<body style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;-webkit-font-smoothing:antialiased;-webkit-text-size-adjust:none;width:100%!important;background-color:#ffffff;height:100%;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif;" > + <table class="body-wrap" style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;width:100%;" > + <tr style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" > + <td style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" ></td> + <td class="container" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;display:block!important;max-width:960px!important;margin-top:0 !important;margin-bottom:0 !important;margin-right:auto !important;margin-left:auto !important;clear:both!important;" > + <div class="content" style="padding-top:15px;padding-bottom:15px;padding-right:15px;padding-left:15px;max-width:960px;margin-top:0;margin-bottom:0;margin-right:auto;margin-left:auto;display:block;" > + <table style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;width:100%;" > + <tr style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" > + <td style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" > + <h2 style="margin-top:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;line-height:1;margin-bottom:15px;color:#000;text-align:center;font-weight:400;font-size:30px;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif;" ><br style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" /> + <span style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;font-size:.5em;line-height:2em;" ><singleline style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" ><a href="https://luxagraf.net/range/" style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;color:#000;text-decoration:none;border-bottom-width:1px;border-bottom-style:solid;border-bottom-color:#ddd;font-weight:normal;" >✪ Range</a> — {{object.get_issue_str}} — {{object.pub_date|date:"F"}} <span>{{object.pub_date|date:"j, Y"}}</span></singleline></span></h2> + <h1 style="margin-top:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;font-family:mffnweb, 'Lora', 'Lucida Serif', Lucida, Georgia, serif;line-height:1;margin-bottom:15px;color:#000;text-align:center;font-weight:400;font-size:44px;" ><singleline style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >{{object.title|safe|smartypants}}</singleline></h1> + + <hr style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;width:50%;margin-top:40px;margin-bottom:40px;margin-right:auto;margin-left:auto;border-width:0;border-top-width:1px;border-top-style:solid;border-top-color:#ddd;" /> + <a href="https://luxagraf.net{{object.post.get_absolute_url}}" style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;border-width:0;color:#000;font-weight:600;text-decoration:none;border-bottom-width:1px;border-bottom-style:solid;border-bottom-color:#ddd;" > + {% include "lib/friends_featured_img.html" with image=object.featured_image %} + </a> + <br style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" /> + <br style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" /> + + If you'd like to view a larger version, read some backstory, and see a video of the development process in Darktable, head on over to: <a href="https://luxagraf.net{{object.post.get_absolute_url}}">https://luxagraf.net{{object.post.get_absolute_url}}</a> + + <hr style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;width:50%;margin-top:40px;margin-bottom:40px;margin-right:auto;margin-left:auto;border-width:0;border-top-width:1px;border-top-style:solid;border-top-color:#ddd;" /> + </td> + </tr> + </table> + </div> +</td> +<td style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" ></td> +</tr> +</table> +<table class="footer-wrap" style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;width:100%;clear:both!important;color:#999;font-family:helvetica !important;font-size:10px !important;" > + <tr style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" > + <td style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" ></td> + <td class="container" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;display:block!important;max-width:960px!important;margin-top:0 !important;margin-bottom:0 !important;margin-right:auto !important;margin-left:auto !important;clear:both!important;" > + + + <div class="content" style="padding-top:15px;padding-bottom:15px;padding-right:15px;padding-left:15px;max-width:960px;margin-top:0;margin-bottom:0;margin-right:auto;margin-left:auto;display:block;" > + <table style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;width:100%;" > + <tr style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" > + <td align="center" style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" > + <p style="margin-top:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;margin-bottom:1.4em;font-weight:400;line-height:1.5;hyphens:auto;font-size:14px;" >New subscriber?<br style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" /> + + Browse the <a href="https://luxagraf.net/range/" style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;font-weight:600;border-bottom-width:1px;border-bottom-style:solid;border-bottom-color:#ddd;color:#333;text-decoration:none;" >online archives</a> here.</p> + <p style="margin-top:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;margin-bottom:1.4em;font-weight:400;line-height:1.5;hyphens:auto;font-size:14px;" ><em style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >Range</em>?<br style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" /> + + A weekly letter from <a href="https://luxagraf.net/" style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;font-weight:600;border-bottom-width:1px;border-bottom-style:solid;border-bottom-color:#ddd;color:#333;text-decoration:none;" >Scott Gilberson</a>, <br style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" />also known as luxagraf.<br style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" /></p> + +<p style="margin-top:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;margin-bottom:1.4em;font-weight:400;line-height:1.5;hyphens:auto;font-size:14px;" > + Shipped from Points Unknown, USA.<br style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" /> + Explained <a href="https://luxagraf.net/range" style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;font-weight:600;border-bottom-width:1px;border-bottom-style:solid;border-bottom-color:#ddd;color:#333;text-decoration:none;" >here</a>.</p> + <br style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" /> + <p style="margin-top:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;margin-bottom:1.4em;font-weight:400;line-height:1.5;hyphens:auto;font-size:14px;" >If you enjoy this, <br style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" /> + please consider forwarding it to a friend.</p> + <p style="margin-top:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;margin-bottom:1.4em;font-weight:400;line-height:1.5;hyphens:auto;font-size:14px;" >✪ </p> + <p style="margin-top:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;font-weight:normal;margin-bottom:1.4em;line-height:1.5;hyphens:auto;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif !important;color:#999;font-size:14px;" >You can always: <a href="https://luxagraf.net{{subscriber.unsubscribe_activate_url}}" style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;font-weight:600;text-decoration:underline;color:#999 !important;" >Unsubscribe</a> instantly.</p> + </td> + </tr> + </table> + </div> + + </td> + <td style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" ></td> + </tr> +</table> +</html> + + diff --git a/app/lttr/templates/lttr/emails/range_plain_text_email.txt b/app/lttr/templates/lttr/emails/range_plain_text_email.txt new file mode 100644 index 0000000..2044e44 --- /dev/null +++ b/app/lttr/templates/lttr/emails/range_plain_text_email.txt @@ -0,0 +1,19 @@ +Greetings Range subscribers- + +I respect your desire for plain text email, but there really isn't a way to do a photo newsletter in plain text, except to say, here's a link to the web-based version: + +<https://luxagraf.net{{ object.get_absolute_url }}> + +----- + +You're getting this email because you signed up for + +Scott Gilbertson's (luxagraf)[https://luxagraf.net/] photo newsletter, + +*Range* [https://luxagraf.net/range/] + +If you're new, you can explore past letters here: [https://luxagraf.net/range/] + +You can always: Unsubscribe [https://luxagraf.net{{subscriber.unsubscribe_activate_url}}] instantly. + +[https://luxagraf.net/] ✪ [https://luxagraf.net/range/] diff --git a/app/lttr/templates/lttr/emails/test-friends_html_email.html b/app/lttr/templates/lttr/emails/test-friends_html_email.html new file mode 100644 index 0000000..d1e2f5a --- /dev/null +++ b/app/lttr/templates/lttr/emails/test-friends_html_email.html @@ -0,0 +1,250 @@ +{% load typogrify_tags %} +<!DOCTYPE html> +<html lang="en" style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" > +<head style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" > + <meta name="viewport" content="width=device-width" style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" /> + <meta name=”robot” content=”noindex” style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" > + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" /> + <title style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >{{object.title}}</title> + + <style type="text/css" style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" > +@font-face { + font-family: 'mffnweb'; + src: url('https://luxagraf.net/media/fonts/ffmn.woff2') format('woff2'); + src: url('https://luxagraf.net/media/fonts/ffmn.woff') format('woff'); + font-weight: 400; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: 'mffnbweb'; + src: url('https://luxagraf.net/media/fonts/ffmn.woff2') format('woff2'); + src: url('https://luxagraf.net/media/fonts/ffmn.woff') format('woff'); + font-weight: 700; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: 'mffweb'; + src: url('https://luxagraf.net/media/fonts/ffmpb.woff2') format('woff2'); + src: url('https://luxagraf.net/media/fonts/ffmpb.woff') format('woff'); + font-weight: 400; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: 'mffweb'; + src: url('https://luxagraf.net/media/fonts/ffmbi.woff2') format('woff2'); + src: url('https://luxagraf.net/media/fonts/ffmbi.woff') format('woff'); + font-weight: 400; + font-style: italic; + font-display: swap; +} + +.tk-ff-meta-web-pro { font-family: mffnbweb,sans-serif; } +.tk-mffnweb { font-family: mffnweb,serif; } +* { + margin:0; + padding:0; +} +* { } +sup, sub { + vertical-align: baseline; + position: relative; + top: -0.4em; +} +sub { + top: 0.4em; +} +img { + max-width: 100%; +} +img.fullbleed { display: inline; border-radius: 3px; margin-bottom: 1.5em; width: 100% !important; max-width: 100% !important; height: auto !important; max-height: auto !important; } +p img.fullbleed { margin-bottom: 0px; } +.collapse { + margin:0; + padding:0; +} +body { + -webkit-font-smoothing:antialiased; + -webkit-text-size-adjust:none; + width: 100%!important; + background-color: #ffffff; + height: 100%; + font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif; +} +a { color: #000; font-weight: 600; text-decoration: none; border-bottom: 1px solid #ddd; } +.btn { + text-decoration:none; + color: #FFF; + background-color: #666; + padding:10px 16px; + font-weight:bold; + margin-right:10px; + text-align:center; + cursor:pointer; + display: inline-block; +} +p.callout { + padding:15px; + background-color:#ECF8FF; + margin-bottom: 15px; +} +.callout a { + font-weight:bold; + color: #2BA6CB; +} +.highlight { background-color: #ffffb2;} +figure { border-top: 1px solid #ddd; border-bottom: 1px solid #ddd; padding-top: 20px; padding-bottom: 20px; margin-bottom: 30px; } +figcaption { text-align: center; font-size: .8em; } +.sp { font-size: .85em; text-transform: uppercase; font-weight: bold; letter-spacing: 1px; } +table.head-wrap { width: 100%;} +table.body-wrap { width: 100%;} +table.footer-wrap { + width: 100%; + clear:both!important; + color: #999; + font-family: helvetica !important; + font-size: 10px !important; +} +.footer-wrap .container .content p { + font-size: 14px; +} +.footer-wrap .container .content a { color: #333; text-decoration: none; } +.footnotes ol li { font-size: .8em; } +.footnotes ol li p { font-size: .8em; } +.footnotes hr { display: none; } +h1,h2,h3,h4,h5,h6 { +font-family: mffnweb, 'Lora', 'Lucida Serif', Lucida, Georgia, serif; +line-height: 1; +margin-bottom:15px; +color:#000; +text-align: center; +} +h1 small, h2 small, h3 small, h4 small, h5 small, h6 small { font-size: 60%; color: #6f6f6f; line-height: 0; text-transform: none; } +h1 { font-weight:400; font-size: 44px;} +h2 { font-weight:400; font-size: 30px;} +h4 { font-weight:500; font-size: 23px;} +h5 { font-weight:500; font-size: 23px; font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif; text-align: left; } +h3, h6 { font-weight:400; font-size: 32px; font-style: italic; margin-top: 40px; text-transform: none; color:#000; text-align: left;} +h2 { font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif; } +h2 a { font-weight: normal; } +.collapse { margin:0!important;} +p, ul, ol { + margin-bottom: 1.4em; + font-weight: 400; + + font-size:17px; + line-height:1.5; + hyphens: auto; +} +hr { width: 50%; margin: 40px auto; border: 0; border-top: 1px solid #ddd; } +p.quote { padding-left: 10px; border-left: 2px solid #ddd; } +blockquote { border-left: 4px solid #efefef; padding-left: 15px; font-style: italic; } +p.lead { font-size:17px; } +p.last { margin-bottom:0px; } +ul li, ol li { + margin-left: 35px; + list-style-position: outside; +} +.container { + display:block!important; + max-width:720px!important; + margin:0 auto!important; + clear:both!important; +} +.content { + padding:17px; + max-width:720px; + margin:0 auto; + display:block; +} +.content table { width: 100%; } +.clear { display: block; clear: both; } +@media only screen and (max-width: 700px) { + + a[class="btn"] { display:block!important; margin-bottom:10px!important; background-image:none!important; margin-right:0!important;} + img.fullbleed { margin-bottom: 1.5em; width: 100%; height: auto !important; } + p { font-size: 16px;} + h1 { font-size: 36px; } + div[class="column"] { width: auto!important; float:none!important;} + + table.social div[class="column"] { + width:auto!important; + } +} + +</style> + +</head> +<body style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;-webkit-font-smoothing:antialiased;-webkit-text-size-adjust:none;width:100%!important;background-color:#ffffff;height:100%;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif;" > + <table class="body-wrap" style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;width:100%;" > + <tr style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" > + <td style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" ></td> + <td class="container" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;display:block!important;max-width:720px!important;margin-top:0 !important;margin-bottom:0 !important;margin-right:auto !important;margin-left:auto !important;clear:both!important;" > + <div class="content" style="padding-top:15px;padding-bottom:15px;padding-right:15px;padding-left:15px;max-width:720px;margin-top:0;margin-bottom:0;margin-right:auto;margin-left:auto;display:block;" > + <table style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;width:100%;" > + <tr style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" > + <td style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" > + <h2 style="margin-top:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;line-height:1;margin-bottom:15px;color:#000;text-align:center;font-weight:400;font-size:30px;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif;" ><br style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" /> + <span style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;font-size:.5em;line-height:2em;" ><singleline style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" ><a href="https://luxagraf.net/friends/" style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;color:#000;text-decoration:none;border-bottom-width:1px;border-bottom-style:solid;border-bottom-color:#ddd;font-weight:normal;" >Friends of a Long Year</a> — {{object.get_issue_str}} — {{object.pub_date|date:"F"}} <span>{{object.pub_date|date:"j, Y"}}</span></singleline></span></h2> + <h1 style="margin-top:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;font-family:mffnweb, 'Lora', 'Lucida Serif', Lucida, Georgia, serif;line-height:1;margin-bottom:15px;color:#000;text-align:center;font-weight:400;font-size:44px;" ><singleline style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >{{object.title|safe|smartypants}}</singleline></h1> + + <hr style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;width:50%;margin-top:40px;margin-bottom:40px;margin-right:auto;margin-left:auto;border-width:0;border-top-width:1px;border-top-style:solid;border-top-color:#ddd;" /> + <a href="https://luxagraf.net{{object.post.get_absolute_url}}" style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;border-width:0;color:#000;font-weight:600;text-decoration:none;border-bottom-width:1px;border-bottom-style:solid;border-bottom-color:#ddd;" > + {% include "lib/friends_featured_img.html" with image=object.featured_image %} + </a> + <br style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" /> + <br style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" /> + + {{object.body_email_html|safe|smartypants}} + + <hr style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;width:50%;margin-top:40px;margin-bottom:40px;margin-right:auto;margin-left:auto;border-width:0;border-top-width:1px;border-top-style:solid;border-top-color:#ddd;" /> + </td> + </tr> + </table> + </div> +</td> +<td style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" ></td> +</tr> +</table> +<table class="footer-wrap" style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;width:100%;clear:both!important;color:#999;font-family:helvetica !important;font-size:10px !important;" > + <tr style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" > + <td style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" ></td> + <td class="container" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;display:block!important;max-width:720px!important;margin-top:0 !important;margin-bottom:0 !important;margin-right:auto !important;margin-left:auto !important;clear:both!important;" > + + + <div class="content" style="padding-top:15px;padding-bottom:15px;padding-right:15px;padding-left:15px;max-width:720px;margin-top:0;margin-bottom:0;margin-right:auto;margin-left:auto;display:block;" > + <table style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;width:100%;" > + <tr style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" > + <td align="center" style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" > + + <p style="margin-top:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;margin-bottom:1.4em;font-weight:400;line-height:1.5;hyphens:auto;font-size:14px;" >New subscriber?<br style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" /> + + Browse the <a href="https://luxagraf.net/friends/" style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;font-weight:600;border-bottom-width:1px;border-bottom-style:solid;border-bottom-color:#ddd;color:#333;text-decoration:none;" >online archives</a> here.</p> + <p style="margin-top:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;margin-bottom:1.4em;font-weight:400;line-height:1.5;hyphens:auto;font-size:14px;" >⫹⫺</p> + <p style="margin-top:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;margin-bottom:1.4em;font-weight:400;line-height:1.5;hyphens:auto;font-size:14px;" ><em style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >Friends</em>?<br style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" /> + + A monthly letter from <a href="https://luxagraf.net/" style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;font-weight:600;border-bottom-width:1px;border-bottom-style:solid;border-bottom-color:#ddd;color:#333;text-decoration:none;" >Scott Gilberson</a>, <br style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" />also known as luxagraf..<br style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" /></p> + + <p style="margin-top:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;margin-bottom:1.4em;font-weight:400;line-height:1.5;hyphens:auto;font-size:14px;" >⫹⫺</p> +<p style="margin-top:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;margin-bottom:1.4em;font-weight:400;line-height:1.5;hyphens:auto;font-size:14px;" > + Shipped from Points Unknown, USA.<br style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" /> + Explained <a href="https://luxagraf.net/jrnl/2020/11/invitation" style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;font-weight:600;border-bottom-width:1px;border-bottom-style:solid;border-bottom-color:#ddd;color:#333;text-decoration:none;" >here</a>.</p> + <br style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" /> + <p style="margin-top:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;margin-bottom:1.4em;font-weight:400;line-height:1.5;hyphens:auto;font-size:14px;" >If you enjoy this, <br style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" /> + please consider forwarding it to a friend. <br style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" />We are after all, friends of a long year here.</p> + <p style="margin-top:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;margin-bottom:1.4em;font-weight:400;line-height:1.5;hyphens:auto;font-size:14px;" >⫹⫺</p> + <p style="margin-top:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;font-weight:normal;margin-bottom:1.4em;line-height:1.5;hyphens:auto;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif !important;color:#999;font-size:14px;" >You can always: <a href="https://luxagraf.net{{subscriber.unsubscribe_activate_url}}" style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;font-weight:600;text-decoration:underline;color:#999 !important;" >Unsubscribe</a> instantly.</p> + </td> + </tr> + </table> + </div> + + </td> + <td style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" ></td> + </tr> +</table> +</html> + + diff --git a/app/lttr/templates/lttr/emails/test-friends_plain_text_email.txt b/app/lttr/templates/lttr/emails/test-friends_plain_text_email.txt new file mode 100644 index 0000000..286b527 --- /dev/null +++ b/app/lttr/templates/lttr/emails/test-friends_plain_text_email.txt @@ -0,0 +1,16 @@ + +{{ object.email_encode|safe }} + +----- + +You're getting this email because you signed up for + +Scott Gilbertson's (luxagraf)[https://luxagraf.net/] newsletter, + +*Friends of a Long Year* [https://luxagraf.net/friends/] + +If you're new, you can explore past letters here: [https://luxagraf.net/friends/] + +You can always: Unsubscribe [https://luxagraf.net{{subscriber.unsubscribe_activate_url}}] instantly. + +[https://luxagraf.net/] ✪ [https://luxagraf.net/friends/] diff --git a/app/lttr/templates/lttr/emails/test-range_html_email.html b/app/lttr/templates/lttr/emails/test-range_html_email.html new file mode 100644 index 0000000..d11479c --- /dev/null +++ b/app/lttr/templates/lttr/emails/test-range_html_email.html @@ -0,0 +1,239 @@ +{% load typogrify_tags %} +<!DOCTYPE html> +<html lang="en" style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" > +<head style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" > + <meta name="viewport" content="width=device-width" style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" /> + <meta name=”robot” content=”noindex” style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" > + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" /> + <title style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >{{object.title}}</title> + + <style type="text/css" style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" > +@font-face { + font-family: 'mffnweb'; + src: url('https://luxagraf.net/media/fonts/ffmn.woff2') format('woff2'); + src: url('https://luxagraf.net/media/fonts/ffmn.woff') format('woff'); + font-weight: 400; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: 'mffnbweb'; + src: url('https://luxagraf.net/media/fonts/ffmn.woff2') format('woff2'); + src: url('https://luxagraf.net/media/fonts/ffmn.woff') format('woff'); + font-weight: 700; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: 'mffweb'; + src: url('https://luxagraf.net/media/fonts/ffmpb.woff2') format('woff2'); + src: url('https://luxagraf.net/media/fonts/ffmpb.woff') format('woff'); + font-weight: 400; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: 'mffweb'; + src: url('https://luxagraf.net/media/fonts/ffmbi.woff2') format('woff2'); + src: url('https://luxagraf.net/media/fonts/ffmbi.woff') format('woff'); + font-weight: 400; + font-style: italic; + font-display: swap; +} + +.tk-ff-meta-web-pro { font-family: mffnbweb,sans-serif; } +.tk-mffnweb { font-family: mffnweb,serif; } +* { + margin:0; + padding:0; +} +* { } +sup, sub { + vertical-align: baseline; + position: relative; + top: -0.4em; +} +sub { + top: 0.4em; +} +img { + width: 846px; +} +p img.fullbleed { margin-bottom: 0px; } +.collapse { + margin:0; + padding:0; +} +body { + -webkit-font-smoothing:antialiased; + -webkit-text-size-adjust:none; + width: 100%!important; + background-color: #ffffff; + height: 100%; + font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif; +} +a { color: #000; font-weight: 600; text-decoration: none; border-bottom: 1px solid #ddd; } +.btn { + text-decoration:none; + color: #FFF; + background-color: #666; + padding:10px 16px; + font-weight:bold; + margin-right:10px; + text-align:center; + cursor:pointer; + display: inline-block; +} +p.callout { + padding:15px; + background-color:#ECF8FF; + margin-bottom: 15px; +} +.callout a { + font-weight:bold; + color: #2BA6CB; +} +.highlight { background-color: #ffffb2;} +figure { border-top: 1px solid #ddd; border-bottom: 1px solid #ddd; padding-top: 20px; padding-bottom: 20px; margin-bottom: 30px; } +figcaption { text-align: center; font-size: .8em; } +table.head-wrap { width: 100%;} +table.body-wrap { width: 100%;} +table.footer-wrap { + width: 100%; + clear:both!important; + color: #999; + font-family: helvetica !important; + font-size: 10px !important; +} +.footer-wrap .container .content p { + font-size: 14px; +} +.footer-wrap .container .content a { color: #333; text-decoration: none; } +.footnotes ol li { font-size: .8em; } +.footnotes ol li p { font-size: .8em; } +.footnotes hr { display: none; } +h1,h2,h3,h4,h5,h6 { +font-family: mffnweb, 'Lucida Serif', Georgia, serif; +line-height: 1; +margin-bottom:15px; +color:#000; +text-align: center; +} +h1 small, h2 small, h3 small, h4 small, h5 small, h6 small { font-size: 60%; color: #6f6f6f; line-height: 0; text-transform: none; } +h1 { font-weight:400; font-size: 44px;} +h2 { font-weight:400; font-size: 30px;} +h4 { font-weight:500; font-size: 23px;} +h5 { font-weight:500; font-size: 23px; font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif; text-align: left; } +h3, h6 { font-weight:400; font-size: 32px; font-style: italic; margin-top: 40px; text-transform: none; color:#000; text-align: left;} +h2 { font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif; } +h2 a { font-weight: normal; } +.collapse { margin:0!important;} +p, ul, ol { + margin-bottom: 1.4em; + font-weight: 400; + + font-size:17px; + line-height:1.5; + hyphens: auto; +} +hr { width: 50%; margin: 40px auto; border: 0; border-top: 1px solid #ddd; } +p.quote { padding-left: 10px; border-left: 2px solid #ddd; } +blockquote { border-left: 4px solid #efefef; padding-left: 15px; font-style: italic; } +ul li, ol li { + margin-left: 35px; + list-style-position: outside; +} +.container { + display:block!important; + max-width:960px!important; + margin:0 auto!important; + clear:both!important; +} +.content { + padding:17px; + max-width:960px; + margin:0 auto; + display:block; +} +.content table { width: 100%; } +.clear { display: block; clear: both; } +@media only screen and (max-width: 700px) { + + a[class="btn"] { display:block!important; margin-bottom:10px!important; background-image:none!important; margin-right:0!important;} + p { font-size: 16px;} + h1 { font-size: 36px; } + div[class="column"] { width: auto!important; float:none!important;} + +} + +</style> + +</head> +<body style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;-webkit-font-smoothing:antialiased;-webkit-text-size-adjust:none;width:100%!important;background-color:#ffffff;height:100%;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif;" > + <table class="body-wrap" style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;width:100%;" > + <tr style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" > + <td style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" ></td> + <td class="container" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;display:block!important;max-width:960px!important;margin-top:0 !important;margin-bottom:0 !important;margin-right:auto !important;margin-left:auto !important;clear:both!important;" > + <div class="content" style="padding-top:15px;padding-bottom:15px;padding-right:15px;padding-left:15px;max-width:960px;margin-top:0;margin-bottom:0;margin-right:auto;margin-left:auto;display:block;" > + <table style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;width:100%;" > + <tr style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" > + <td style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" > + <h2 style="margin-top:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;line-height:1;margin-bottom:15px;color:#000;text-align:center;font-weight:400;font-size:30px;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif;" ><br style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" /> + <span style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;font-size:.5em;line-height:2em;" ><singleline style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" ><a href="https://luxagraf.net/range/" style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;color:#000;text-decoration:none;border-bottom-width:1px;border-bottom-style:solid;border-bottom-color:#ddd;font-weight:normal;" >✪ Range</a> — {{object.get_issue_str}} — {{object.pub_date|date:"F"}} <span>{{object.pub_date|date:"j, Y"}}</span></singleline></span></h2> + <h1 style="margin-top:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;font-family:mffnweb, 'Lora', 'Lucida Serif', Lucida, Georgia, serif;line-height:1;margin-bottom:15px;color:#000;text-align:center;font-weight:400;font-size:44px;" ><singleline style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >{{object.title|safe|smartypants}}</singleline></h1> + + <hr style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;width:50%;margin-top:40px;margin-bottom:40px;margin-right:auto;margin-left:auto;border-width:0;border-top-width:1px;border-top-style:solid;border-top-color:#ddd;" /> + <a href="https://luxagraf.net{{object.post.get_absolute_url}}" style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;border-width:0;color:#000;font-weight:600;text-decoration:none;border-bottom-width:1px;border-bottom-style:solid;border-bottom-color:#ddd;" > + {% include "lib/friends_featured_img.html" with image=object.featured_image %} + </a> + <br style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" /> + <br style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" /> + + If you'd like to view a larger version, read some backstory, and see a video of the development process in Darktable, head on over to: <a href="https://luxagraf.net{{object.post.get_absolute_url}}">https://luxagraf.net{{object.post.get_absolute_url}}</a> + + <hr style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;width:50%;margin-top:40px;margin-bottom:40px;margin-right:auto;margin-left:auto;border-width:0;border-top-width:1px;border-top-style:solid;border-top-color:#ddd;" /> + </td> + </tr> + </table> + </div> +</td> +<td style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" ></td> +</tr> +</table> +<table class="footer-wrap" style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;width:100%;clear:both!important;color:#999;font-family:helvetica !important;font-size:10px !important;" > + <tr style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" > + <td style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" ></td> + <td class="container" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;display:block!important;max-width:960px!important;margin-top:0 !important;margin-bottom:0 !important;margin-right:auto !important;margin-left:auto !important;clear:both!important;" > + + + <div class="content" style="padding-top:15px;padding-bottom:15px;padding-right:15px;padding-left:15px;max-width:960px;margin-top:0;margin-bottom:0;margin-right:auto;margin-left:auto;display:block;" > + <table style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;width:100%;" > + <tr style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" > + <td align="center" style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" > + <p style="margin-top:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;margin-bottom:1.4em;font-weight:400;line-height:1.5;hyphens:auto;font-size:14px;" >New subscriber?<br style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" /> + + Browse the <a href="https://luxagraf.net/range/" style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;font-weight:600;border-bottom-width:1px;border-bottom-style:solid;border-bottom-color:#ddd;color:#333;text-decoration:none;" >online archives</a> here.</p> + <p style="margin-top:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;margin-bottom:1.4em;font-weight:400;line-height:1.5;hyphens:auto;font-size:14px;" ><em style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >Range</em>?<br style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" /> + + A weekly letter from <a href="https://luxagraf.net/" style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;font-weight:600;border-bottom-width:1px;border-bottom-style:solid;border-bottom-color:#ddd;color:#333;text-decoration:none;" >Scott Gilberson</a>, <br style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" />also known as luxagraf.<br style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" /></p> + +<p style="margin-top:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;margin-bottom:1.4em;font-weight:400;line-height:1.5;hyphens:auto;font-size:14px;" > + Shipped from Points Unknown, USA.<br style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" /> + Explained <a href="https://luxagraf.net/range/" style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;font-weight:600;border-bottom-width:1px;border-bottom-style:solid;border-bottom-color:#ddd;color:#333;text-decoration:none;" >here</a>.</p> + <br style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" /> + <p style="margin-top:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;margin-bottom:1.4em;font-weight:400;line-height:1.5;hyphens:auto;font-size:14px;" >If you enjoy this, <br style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" /> + please consider forwarding it to a friend.</p> + <p style="margin-top:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;margin-bottom:1.4em;font-weight:400;line-height:1.5;hyphens:auto;font-size:14px;" >✪ </p> + <p style="margin-top:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;font-weight:normal;margin-bottom:1.4em;line-height:1.5;hyphens:auto;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif !important;color:#999;font-size:14px;" >You can always: <a href="https://luxagraf.net{{subscriber.unsubscribe_activate_url}}" style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;font-weight:600;text-decoration:underline;color:#999 !important;" >Unsubscribe</a> instantly.</p> + </td> + </tr> + </table> + </div> + + </td> + <td style="margin-top:0;margin-bottom:0;margin-right:0;margin-left:0;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" ></td> + </tr> +</table> +</html> + + diff --git a/app/lttr/templates/lttr/emails/test-range_plain_text_email.txt b/app/lttr/templates/lttr/emails/test-range_plain_text_email.txt new file mode 100644 index 0000000..970c42f --- /dev/null +++ b/app/lttr/templates/lttr/emails/test-range_plain_text_email.txt @@ -0,0 +1,19 @@ +Greetings Range subscribers- + +While I respect your desire for plain text email, there really isn't a way to do a photo newsletter in plain text other than to say, here's a link to the web-based version: + +<https://luxagraf.net{{ object.get_absolute_url }}> + +----- + +You're getting this email because you signed up for + +Scott Gilbertson's (luxagraf)[https://luxagraf.net/] photo newsletter, + +*Range* [https://luxagraf.net/range/] + +If you're new, you can explore past letters here: [https://luxagraf.net/range/] + +You can always: Unsubscribe [https://luxagraf.net{{subscriber.unsubscribe_activate_url}}] instantly. + +[https://luxagraf.net/] ✪ [https://luxagraf.net/range/] diff --git a/app/lttr/templates/lttr/message/subscribe.html b/app/lttr/templates/lttr/message/subscribe.html new file mode 100644 index 0000000..56ccbcb --- /dev/null +++ b/app/lttr/templates/lttr/message/subscribe.html @@ -0,0 +1,23 @@ +{% load i18n %}<!DOCTYPE html> + +<html> +<head> + <meta charset="utf-8"> + <title>{% blocktrans with title=newsletter.title %}Subscription to {{ title }}{% endblocktrans %} +</title> +</head> +<body> +{% blocktrans with name=subscription.name title=newsletter.title domain=site.domain url=subscription.subscribe_activate_url intro=newsletter.intro %} + +<p>Hola-</p> + +<p>Someone, hopefully you, asked to subscribe to luxagraf's {{ title }} newsletter. {{ intro }}</p> + +<p>Please click this link to active your subscription:</p> + +https://{{ domain }}{{ url }} +{% endblocktrans %} +<p>cheers<br /> +Scott</p> +</body> +</html> diff --git a/app/lttr/templates/lttr/message/subscribe.txt b/app/lttr/templates/lttr/message/subscribe.txt new file mode 100644 index 0000000..edbc467 --- /dev/null +++ b/app/lttr/templates/lttr/message/subscribe.txt @@ -0,0 +1,9 @@ +Hola- + +Someone, hopefully you, asked to subscribe to luxagraf's {{ newsletter.title }} newsletter. {{ newsletter.intro }} + +Please click this link to active your subscription: + +https://{{ site.domain }}{{ subscription.subscribe_activate_url }} + +-Scott diff --git a/app/lttr/templates/lttr/message/subscribe_subject.txt b/app/lttr/templates/lttr/message/subscribe_subject.txt new file mode 100644 index 0000000..f4660e0 --- /dev/null +++ b/app/lttr/templates/lttr/message/subscribe_subject.txt @@ -0,0 +1 @@ +Confirm Your Subscription to {{newsletter.title}} diff --git a/app/lttr/templates/lttr/message/unsubscribe.html b/app/lttr/templates/lttr/message/unsubscribe.html new file mode 100644 index 0000000..4b1a86b --- /dev/null +++ b/app/lttr/templates/lttr/message/unsubscribe.html @@ -0,0 +1,19 @@ +{% load i18n %}<!DOCTYPE html> + +<html> +<head> + <meta charset="utf-8"> + <title>{% blocktrans with title=newsletter.title %}Unsubscription from {{ title }}{% endblocktrans %}</title> +</head> +<body> +{% blocktrans with name=subscription.name title=newsletter.title domain=site.domain url=subscription.unsubscribe_activate_url %}Dear {{ name }}, + +you, or someone in your name requested unsubscription from {{ title }}. + +If you would like to confirm your unsubscription, please follow this activation link: +http://{{ domain }}{{ url }} + +Kind regards,{% endblocktrans %} +{{ newsletter.sender }} +</body> +</html> diff --git a/app/lttr/templates/lttr/message/unsubscribe.txt b/app/lttr/templates/lttr/message/unsubscribe.txt new file mode 100644 index 0000000..ab31fa5 --- /dev/null +++ b/app/lttr/templates/lttr/message/unsubscribe.txt @@ -0,0 +1,9 @@ +{% load i18n %}{% blocktrans with name=subscription.name title=newsletter.title domain=site.domain url=subscription.unsubscribe_activate_url %}Dear {{ name }}, + +you, or someone in your name requested unsubscription from {{ title }}. + +If you would like to confirm your unsubscription, please follow this activation link: +http://{{ domain }}{{ url }} + +Kind regards,{% endblocktrans %} +{{ newsletter.sender }} diff --git a/app/lttr/templates/lttr/message/unsubscribe_subject.txt b/app/lttr/templates/lttr/message/unsubscribe_subject.txt new file mode 100644 index 0000000..49c68ef --- /dev/null +++ b/app/lttr/templates/lttr/message/unsubscribe_subject.txt @@ -0,0 +1 @@ +{% load i18n %}{{ newsletter.title }} - {% trans "Confirm unsubscription" %} diff --git a/app/lttr/templates/lttr/newslettermailing_detail.html b/app/lttr/templates/lttr/newslettermailing_detail.html new file mode 100644 index 0000000..1a89200 --- /dev/null +++ b/app/lttr/templates/lttr/newslettermailing_detail.html @@ -0,0 +1,155 @@ +{% extends 'base.html' %} +{% load typogrify_tags %} +{% block sitename %} +<head itemscope itemtype="http://schema.org/WebSite"> + <title itemprop='name'>Luxagraf: thoughts on ecology, culture, travel, photography, walking and other ephemera</title> + <link rel="canonical" href="https://luxagraf.net/">{%endblock%} + + {%block extrahead%} + <link rel="canonical" href="https://luxagraf.net{{object.get_absolute_url}}" /> + <meta property="og:type" content="article" /> + <meta property="og:title" content="{{object.title|safe}}" /> + <meta property="og:url" content="https://luxagraf.net{{object.get_absolute_url}}" /> + <meta property="og:description" content="{{object.meta_description}}" /> + <meta property="article:published_time" content="{{object.pub_date|date:'c'}}" /> + <meta property="article:author" content="Scott Gilbertson" /> + <meta property="og:site_name" content="Luxagraf" /> + <meta property="og:image" content="{{object.get_featured_image}}" /> + <meta property="og:locale" content="en_US" /> + <meta name="twitter:card" content="summary_large_image"/> + <meta name="twitter:description" content="{{object.meta_description}}"/> + <meta name="twitter:title" content="{{object.title|safe}}"/> + <meta name="twitter:site" content="@luxagraf"/> + <meta name="twitter:domain" content="luxagraf"/> + <meta name="twitter:image:src" content="{{object.get_featured_image}}"/> + <meta name="twitter:creator" content="@luxagraf"/> +<script type="application/ld+json"> +{ + "@context": "https://schema.org", + "@type": "Article", + "mainEntityOfPage": { + "@type": "WebPage", + "@id": "https://luxagraf.net{{object.get_absolute_url}}" + }, + "headline": "{{object.title}}", + "datePublished": "{{object.pub_date|date:'c'}}+04:00", + "dateModified": "{{object.pub_date|date:'c'}}+04:00", + "author": { + "@type": "Person", + "name": "Scott Gilbertson" + }, + "publisher": { + "@type": "Organization", + "name": "Luxagraf", + "logo": { + "@type": "ImageObject", + "url": "https://luxagraf.net/media/img/logo-white.jpg" + } + }, + "description": "{{object.meta_description}}" +} +</script> +{%endblock%} +{%block bodyid%}id="home" class="archive"{%endblock%} + +{% block primary %} +{% block breadcrumbs %}{% include "lib/breadcrumbs.html" with breadcrumbs=breadcrumbs %}{% endblock %} + <main> + <article class="h-entry hentry entry-content content{% with object.get_template_name_display as t %}{%if t == "double" or t == "double-dark" %} post--article--double{%endif%}{%endwith%}" itemscope itemType="http://schema.org/BlogPosting"> + <figure class="large-top-image"> + <a href="{{object.get_absolute_url}}" title="{{object.title}}">{%with image=object.featured_image%} + <img class="u-photo" itemprop="image" sizes="(max-width: 960px) 100vw" + srcset="{{image.get_srcset}}" + src="{{image.get_src}}" + alt="{{image.alt}} photographed by {% if image.photo_credit_source %}{{image.photo_credit_source}}{%else%}luxagraf{%endif%}"> + </a>{%endwith%} + </figure> + <article class="h-entry hentry entry-content content" itemscope itemType="http://schema.org/BlogPosting"> + <header id="header" class="post-header"> + <h1 class="p-name post-title" itemprop="headline">{{object.title|smartypants|safe}}</h1> + {% if object.subtitle %}<h2 class="post-subtitle">{{object.subtitle|smartypants|safe}}</h2>{%endif%} + <div class="post-linewrapper"> + {% if object.location %}<div class="p-location h-adr adr post-location" itemprop="contentLocation" itemscope itemtype="http://schema.org/Place"> + <h3 class="h-adr" itemprop="address" itemscope itemtype="http://schema.org/PostalAddress">{% if object.location.country_name == "United States" %}<span class="p-locality locality" itemprop="addressLocality">{{object.location.name|smartypants|safe}}</span>, <a class="p-region region" href="/jrnl/united-states/" title="travel writing from the United States">{{object.location.state_name|safe}}</a>, <span class="p-country-name" itemprop="addressCountry">U.S.</span>{%else%}<span class="p-region" itemprop="addressRegion">{{object.location.name|smartypants|safe}}</span>, <a class="p-country-name country-name" href="/jrnl/{{object.location.country_slug}}/" title="travel writing from {{object.location.country_name}}"><span itemprop="addressCountry">{{object.location.country_name|safe}}</span></a>{%endif%}</h3> + – <a href="" onclick="showMap({{object.latitude}}, {{object.longitude}}, { type:'point', lat:'{{object.latitude}}', lon:'{{object.longitude}}'}); return false;" title="see a map">Map</a> + </div>{%endif%} + <time class="dt-published published dt-updated post-date" datetime="{{object.pub_date|date:'c'}}" itemprop="datePublished">{{object.pub_date|date:"F"}} <span>{{object.pub_date|date:"j, Y"}}</span></time> + <span class="hide" itemprop="author" itemscope itemtype="http://schema.org/Person">by <a class="p-author h-card" href="/about"><span itemprop="name">Scott Gilbertson</span></a></span> + </div> + </header> + <div id="article" class="e-content post-body" itemprop="articleBody"> + {{object.body_html|safe|smartypants}} + </div> + {%if wildlife or object.field_notes.all or object.books.all %}<div class="entry-footer">{%if wildlife %} + <aside id="wildlife"> + <h3>Fauna and Flora</h3> + {% regroup wildlife by ap.apclass.get_kind_display as wildlife_list %} + <ul> + {% for object_list in wildlife_list %} + <li class="grouper">{{object_list.grouper}}<ul> + {% for object in object_list.list %} + <li>{%if object.ap.body_markdown%}<a href="{% url 'sightings:detail' object.ap.slug %}">{{object}}</a>{%else%}{{object}}{%endif%} </li> + {% endfor %}</ul> + {% endfor %}</ul> + </aside> + {% endif %}{%if object.field_notes.all %} + <aside {% if wildlife %}class="margin-left-none" {%endif%}id="field_notes"> + <h3>Field Notes</h3> + <ul>{% for obj in object.field_notes.all %} + <li><a href="{% url 'fieldnotes:detail' year=obj.pub_date.year month=obj.pub_date|date:"m" slug=obj.slug %}">{{obj}}</a></li> + {% endfor %}</ul> + </aside>{% endif %} + {%if object.books.all %} + <aside id="recommended-reading" {%if object.field_notes.all and wildlife %}class="rr-clear{%endif%}" > + <h3>Recommended Reading</h3> + <ul>{% for obj in object.books.all %} + <li><a href="{% url 'books:detail' slug=obj.slug %}"><img src="{{obj.get_small_image_url}}" /></a></li> + {% endfor %}</ul> + </aside>{% endif %} + </div>{%endif%} + </article> + {% with object.get_next_published as next %} + {% with object.get_previous_published as prev %} + <div class="nav-wrapper"> + <nav id="page-navigation" {%if wildlife or object.field_notes.all or object.books.all %}{%else%}class="page-border-top"{%endif%}> + <ul>{% if prev%} + <li id="prev"><span class="bl">Previous:</span> + <a href="{{ prev.get_absolute_url }}" rel="prev" title=" {{prev.title}}">{{prev.title|safe}}</a> + </li>{%endif%}{% if next%} + <li id="next"><span class="bl">Next:</span> + <a href="{{ next.get_absolute_url }}" rel="next" title=" {{next.title}}">{{next.title|safe}}</a> + </li>{%endif%} + </ul> + </nav>{%endwith%}{%endwith%} + </div> + {% if object.related.all %}<div class="article-afterward related"> + <div class="related-bottom"> + <h6 class="hedtinycaps">You might also enjoy</h6> + <ul class="article-card-list">{% for object in related %} + <li class="article-card-mini"><a href="{{object.get_absolute_url}}" title="{{object.title}}"> + <div class="post-image post-mini-image"> + {% if object.featured_image %} + {% include "lib/img_archive.html" with image=object.featured_image nolightbox=True %} + {% elif object.image %} + {% include "lib/img_archive.html" with image=object.image nolightbox=True %} + {% else %} + <img src="{{object.get_image_url}}" alt="{{ object.title }}" class="u-photo post-image" itemprop="image" />{%endif%} + </div> + <h4 class="p-name entry-title post-title" itemprop="headline">{% if object.title %}{{object.title|safe|smartypants|widont}}{% else %}{{object.common_name}}{%endif%}</h4> + <p class="p-author author hide" itemprop="author"><span class="byline-author" itemscope itemtype="http://schema.org/Person"><span itemprop="name">Scott Gilbertson</span></span></p> + <p class="post-summary"> + <span class="p-location h-adr adr post-location" itemprop="contentLocation" itemscope itemtype="http://schema.org/Place"> + {% if object.location.country_name == "United States" %}{{object.location.state_name}}{%else%}{{object.location.country_name}}{%endif%} + </span> + – + <time class="dt-published published dt-updated post-date" datetime="{{object.pub_date|date:'c'}}"><span>{{object.pub_date|date:" Y"}}</span></time> + </p> + </a> + </li> + {% endfor %}</ul> + </div> + </div>{%endif%} + </main> +{% endblock %} + +{% block js %}{% comment %} <script async src="/media/js/hyphenate.min.js" type="text/javascript"></script>{% endcomment%}{% endblock%} diff --git a/app/lttr/templates/lttr/postcard_subscribe.html b/app/lttr/templates/lttr/postcard_subscribe.html new file mode 100644 index 0000000..7f96503 --- /dev/null +++ b/app/lttr/templates/lttr/postcard_subscribe.html @@ -0,0 +1,29 @@ +{% load typogrify_tags %} +<html style="border:none !important;" dir="ltr" lang="en-US"> +<head> + <meta charset="utf-8"> + <meta http-equiv="x-ua-compatible" content="ie=edge"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <link rel="stylesheet" + href="/media/screenv11.min.css" + media="screen"> +<style> +html { overflow: hidden;} +.card-subscribe fieldset label { + visibility: unset; +} +</style> +</head> +<body> + <form action="" method="post" target='_parent' class="generic-form comment-form card-subscribe">{% csrf_token %} + {% for field in form %} + <fieldset> + {{field.label_tag}} + {%ifequal field.name "address"%}<div class="textarea-rounded">{{ field }}</div>{%else%}{{field}}{%endifequal%} + </fieldset> + {% if forloop.last %}<input type="submit" name="post" class="btn" value="Send Me A Postcard" />{%endif%} + <small class="alert">{% if field.errors %}{{field.errors}}{% endif %}</small> + {%endfor%} + </form> +</body> +</html> diff --git a/app/lttr/templates/lttr/postcard_subscribed.html b/app/lttr/templates/lttr/postcard_subscribed.html new file mode 100644 index 0000000..464fb72 --- /dev/null +++ b/app/lttr/templates/lttr/postcard_subscribed.html @@ -0,0 +1,24 @@ +{% extends 'base.html' %} +{% load typogrify_tags %} + +{% block pagetitle %}Thanks for subscribing! | luxagraf.net {% endblock %} +{% block metadescription %}Thank you, I appreciate you joining the club{% endblock %} + +{% block primary %} + <nav class="breadcrumbs" itemscope itemtype="http://schema.org/BreadcrumbList"> + <span class="nav-item" itemprop="item"> + <a href="/" itemprop="name">Home</a> + <meta itemprop="position" content="1" /> + </span> + <span class="nav-item" itemprop="item"> + <span itemprop="name">postcards</span> + <meta itemprop="position" content="2" /> + </span> + </nav> + <main role="main" id="essay-archive" class="archive-wrapper"> + <div class="archive-intro"> + <h2>Nicely Done!</h2> + <p>We will send you a postcard in the near future.</p> + </div> + </main> +{%endblock%} diff --git a/app/lttr/templates/lttr/range_detail.html b/app/lttr/templates/lttr/range_detail.html new file mode 100644 index 0000000..008a572 --- /dev/null +++ b/app/lttr/templates/lttr/range_detail.html @@ -0,0 +1,184 @@ +{% extends 'base.html' %} +{% load typogrify_tags %} +{% load get_image_by_size %} +{%block htmlclass%}{%endblock%} +{% block sitename %} +<head itemscope itemtype="http://schema.org/WebSite"> + <title itemprop='name'>{{object.title|safe}} by Scott Gilbertson</title> + <link rel="canonical" href="https://luxagraf.net{{object.get_absolute_url}}">{%endblock%} + + {%block extrahead%} + <link rel="canonical" href="https://luxagraf.net{{object.get_absolute_url}}" /> + <meta property="og:type" content="article" /> + <meta property="og:title" content="{{object.title|safe}}" /> + <meta property="og:url" content="https://luxagraf.net{{object.get_absolute_url}}" /> + <meta property="og:description" content="{{object.meta_description}}" /> + <meta property="article:published_time" content="{{object.pub_date|date:'c'}}" /> + <meta property="article:author" content="Scott Gilbertson" /> + <meta property="og:site_name" content="Luxagraf" /> + <meta property="og:image" content="{{object.get_featured_image}}" /> + <meta property="og:locale" content="en_US" /> + <meta name="twitter:card" content="summary_large_image"/> + <meta name="twitter:description" content="{{object.meta_description}}"/> + <meta name="twitter:title" content="{{object.title|safe}}"/> + <meta name="twitter:site" content="@luxagraf"/> + <meta name="twitter:domain" content="luxagraf"/> + <meta name="twitter:image:src" content="{{object.get_featured_image}}"/> + <meta name="twitter:creator" content="@luxagraf"/> +<script type="application/ld+json"> +{ + "@context": "https://schema.org", + "@type": "Article", + "mainEntityOfPage": { + "@type": "WebPage", + "@id": "https://luxagraf.net{{object.get_absolute_url}}" + }, + "headline": "{{object.title}}", + "datePublished": "{{object.pub_date|date:'c'}}+04:00", + "dateModified": "{{object.pub_date|date:'c'}}+04:00", + "author": { + "@type": "Person", + "name": "Scott Gilbertson" + }, + "publisher": { + "@type": "Organization", + "name": "Luxagraf", + "logo": { + "@type": "ImageObject", + "url": "https://luxagraf.net/media/img/logo-white.jpg" + } + }, + "description": "{{object.meta_description}}" +} +</script> +{%endblock%} +{%block bodyid%}id="home" class="friends"{%endblock%} +{% block breadcrumbs %}<nav class="breadcrumbs" itemscope itemtype="http://schema.org/BreadcrumbList"> + <span class="nav-item" itemprop="item"> + <a href="/" itemprop="name">Home</a> + <meta itemprop="position" content="1" /> + </span> + <span class="nav-item" itemprop="item"> + <a href="/range/" itemprop="name">Range</a> + <meta itemprop="position" content="2" /> + </span> + <span class="nav-item" itemprop="item"> + <span itemprop="name">{{object.get_issue_str}}</span> + <meta itemprop="position" content="3" /> + </span> + </nav> +{% endblock %} +{% block primary %} + <main> + <figure class="large-top-image"> + <a href="{{object.get_absolute_url}}" title="{{object.title}}">{%with image=object.featured_image%} + <img style="margin:0;" class="u-photo" itemprop="image" sizes="(max-width: 960px) 100vw" + srcset="{{image.get_srcset}}" + src="{{image.get_src}}" + alt="{{image.alt}} photographed by {% if image.photo_credit_source %}{{image.photo_credit_source}}{%else%}luxagraf{%endif%}"> + </a> + <figcaption class="exif-caption"> + {{image.exif_make}} {{image.exif_model}} {%if image.exif_lens %} with a {{image.exif_lens}} lens, {%endif%} f/{{image.exif_aperture}} for {{image.exif_exposure}} sec at {{image.exif_iso}} ISO. + </figcaption> + </figure>{%endwith%} + <article class="h-entry hentry content" itemscope itemType="http://schema.org/BlogPosting"> + <header id="header" class="post-header"> + <h1 class="p-name post-title" itemprop="headline">{{object.title|smartypants|safe}}</h1> + <div class="post-dateline"> + <time class="dt-published published dt-updated post-date lttr-box" datetime="{{object.pub_date|date:'c'}}" itemprop="datePublished">Image {{object.get_issue_str}} – {{object.pub_date|date:"F j, Y"}}</span></time> + <span class="hide" itemprop="author" itemscope itemtype="http://schema.org/Person">by <a class="p-author h-card" href="/about"><span itemprop="name">Scott Gilbertson</span></a></span> + </div> + </header> + <div id="article" class="e-content entry-content post-body" itemprop="articleBody"> + {{object.body_html|safe|smartypants}} + </div> + {%if object.books.all %}<div class="entry-footer"> + <aside id="recommended-reading" class="" > + <h3>Recommended Reading</h3>{% for obj in object.books.all %} + <div itemprop="mainEntity" itemscope itemtype="http://schema.org/Book"> + <div class="book-cover-wrapper"> + <img src="{{obj.get_image_url}}" alt="{{obj.title}} cover" class="lttr-cover" /> + </div> + <div class="meta-cover"> + <h5 class="post-title book-title" itemprop="name">{{obj.title|smartypants|widont|safe}}</h6> + <h6 class="post-subtitle" itemprop="author" itemscope itemtype="http://schema.org/Person"> + <meta itemprop="name" content="{{obj.author_name}}"/>by {{obj.author_name}}</h5> + <dl class="book-metadata"> + {% if obj.rating %}<dt>Rating</dt><dd class="book-stars"> + {% for i in obj.ratings_range %}{% if i <= obj.get_rating%}★{%else%}☆{%endif%}{%endfor%}</span></dd>{%endif%} + {% if obj.read_in %}<dt>Read</dt> + <dd>{{obj.read_in}}</dd>{%endif%} + {% if obj.pages %}<dt>Pages</dt> + <dd itemprop="numberOfPages">{{obj.pages}}</dd>{%endif%} + {% if obj.publish_date %}<dt>Published</dt> + <dd>{%if obj.publish_place%}{{obj.publish_place}}, {%endif%}{{obj.publish_date}}</dd>{%endif%} + {% if obj.isbn %}<dt>ISBN</dt> + <dd>{{obj.isbn}}</dd>{%endif%} + </dl> + <div class="buy-btn-wrapper"> + {% if obj.isbn %}<a class="buy-btn" href="http://worldcat.org/isbn/{{obj.isbn}}" title="find {{obj.title}} in your local library">Borrow</a>{%endif%} + {% if obj.afflink %}<a class="buy-btn" href="{{obj.afflink}}" title="buy {{obj.title}} at Amazon">Buy</a>{%endif%} + </div> + </div>{%if obj.body_html%} + <div class="thoughts" itemprop="review" itemscope itemtype="http://schema.org/Review"> + <h5>Notes</h5> + <span class="hide" itemprop="reviewRating">{{obj.rating}}</span> + <meta itemprop="author" content="Scott Gilbertson" /> + <meta itemprop="datePublished" content="{{obj.read_date|date:"c"}}"> + <div itemprop="reviewBody">{{obj.body_html|safe|smartypants|widont}}</div> + </div>{%endif%} + </div> + {% endfor %} + </aside>{%endif%} + </article> + + {% with object.get_next_published as next %} + {% with object.get_previous_published as prev %} + <nav class="page-navigation"> + <div>{% if prev%} + <span class="label">Previous:</span> + <a href="{{ prev.get_absolute_url }}" rel="prev" title=" {{prev.title}}">{{prev.title|safe}}</a> + </div>{%endif%}{% if next %} + <div> + <span class="label">Next:</span> + <a href="{{ next.get_absolute_url }}" rel="next" title=" {{next.title}}">{{next.title|safe}}</a> + </div>{%endif%} + </nav>{%endwith%}{%endwith%} + <aside class="narrow donate join"> + <p>You're reading <em>Range</em>, a weekly mailing of a single photograph, along with a few notes, and video of the processing. If you'd like to join us, drop your email in the form below: </p> + <iframe target='_parent' style="border:none !important; background:white; width:100% !important;" title="embedded form for subscribing the the Friends of a Long Year newsletter" src="{% url 'lttr:subscribe' slug='range' %}"></iframe> + </aside> + </div> + {% if object.related.all %}<div class="article-afterward related"> + <div class="related-bottom"> + <h6 class="hedtinycaps">You might also enjoy</h6> + <ul class="article-card-list">{% for object in related %} + <li class="article-card-mini"><a href="{{object.get_absolute_url}}" title="{{object.title}}"> + <div class="post-image post-mini-image"> + {% if object.featured_image %} + {% include "lib/img_archive.html" with image=object.featured_image nolightbox=True %} + {% elif object.image %} + {% include "lib/img_archive.html" with image=object.image nolightbox=True %} + {% else %} + <img src="{{object.get_image_url}}" alt="{{ object.title }}" class="u-photo post-image" itemprop="image" />{%endif%} + </div> + <h4 class="p-name entry-title post-title" itemprop="headline">{% if object.title %}{{object.title|safe|smartypants|widont}}{% else %}{{object.common_name}}{%endif%}</h4> + <p class="p-author author hide" itemprop="author"><span class="byline-author" itemscope itemtype="http://schema.org/Person"><span itemprop="name">Scott Gilbertson</span></span></p> + <p class="post-summary"> + <span class="p-location h-adr adr post-location" itemprop="contentLocation" itemscope itemtype="http://schema.org/Place"> + {% if object.location.country_name == "United States" %}{{object.location.state_name}}{%else%}{{object.location.country_name}}{%endif%} + </span> + – + <time class="dt-published published dt-updated post-date" datetime="{{object.pub_date|date:'c'}}"><span>{{object.pub_date|date:" Y"}}</span></time> + </p> + </a> + </li> + {% endfor %}</ul> + </div> + </div>{%endif%} + </main> +{% endblock %} + +{% block js %}{% comment %} <script async src="/media/js/hyphenate.min.js" type="text/javascript"></script>{% endcomment%}{% endblock%} + + diff --git a/app/lttr/templates/lttr/range_list.html b/app/lttr/templates/lttr/range_list.html new file mode 100644 index 0000000..c6875e9 --- /dev/null +++ b/app/lttr/templates/lttr/range_list.html @@ -0,0 +1,44 @@ +{% extends 'base.html' %} +{% load typogrify_tags %} +{% block pagetitle %}Luxagraf | Range {% endblock %} +{% block metadescription %}A weekly photo, developed.{% endblock %} +{% block breadcrumbs %}{% include "lib/breadcrumbs.html" with breadcrumbs=breadcrumbs %}{% endblock %} +{% block primary %} +<main role="main" class="archive-wrapper">{% for object in object_list %}{% if forloop.first %} + <figure class="large-top-image"> + <a href="{{object.get_absolute_url}}" title="{{object.title}}">{%with image=object.featured_image%} + <img style="margin:0;" class="u-photo" itemprop="image" sizes="(max-width: 960px) 100vw" + srcset="{{image.get_srcset}}" + src="{{image.get_src}}" + alt="{{image.alt}} photographed by {% if image.photo_credit_source %}{{image.photo_credit_source}}{%else%}luxagraf{%endif%}"> + </a>{%endwith%}{%endif%}{%endfor%} + </figure> + <div class="archive-intro"> + <h1 class="list-hed">Range</h1> + <h2 class="list-subhed">A weekly photo, developed.</h2> + <p>Please join us by dropping your email in the form below: </p> + <iframe target='_parent' style="border:none !important; background:white; width:100% !important;" title="embedded form for subscribing the the Friends of a Long Year newsletter" src="{% url 'lttr:subscribe' slug='range' %}"></iframe> + <p><em>Range</em> is a weekly mailing of a single photograph. </p> + <p>If you're interested there is also a link to a video of the RAW image processing in <a href="https://www.darktable.org/">darktable</a>, and sometimes a few words about the process. But the primary purpose is to deliver a single photo to your inbox. Simple and fun.</p> + <p>Yes, I know about Instagram. This is an attempt to reclaim that space, sharing photos with friends, but without all the distractions of the corporate social web, without the endless scroll of photos, likes, stories, comments, whatever. This is just an image delivered once a week to your inbox. I've been trying to think of a way to make it reciprocal, so you can send a picture to my inbox. If you have ideas, <a href="mailto:comments@luxagraf.net">email me</a>.</p> + <p>Unsubscribing is easy. It's <a href="/src/building-your-own-mailing-list-software">self-hosted</a> and <a href="/privacy" title="My privacy policy">respects your privacy</a>. If you don't want an email, there's also <a href="/range/feed.xml">an RSS feed</a>, and it's all archived below.</p> + <p>There's also the <em><a href="/friends/">Friends of a Long Year</a></em> newsletter if you want some stories in your inbox.</p> + </div> + <h3 class="archive-sans">Images</h3> + <div class="archive-grid">{% for object in object_list %} + <article class="h-entry hentry archive-grid-card" itemscope itemType="http://schema.org/Article"> + <div class="card-image"> + <a href="{{object.get_absolute_url}}" title="{{object.title}}"> + {% include "lib/img_archive.html" with image=object.featured_image %} + </a> + </div> + <h2 class="p-name card-hed-it" itemprop="headline"><a href="{{object.get_absolute_url}}" class="u-url" title="{{object.title}}">{{object.title|safe|smartypants|widont}}</a></h2> + <p class="p-author author hide" itemprop="author"><span class="byline-author" itemscope itemtype="http://schema.org/Person"><span itemprop="name">Scott Gilbertson</span></span></p> + <time class="dt-published published dt-updated card-smcaps" datetime="{{object.pub_date|date:'c'}}">{{object.pub_date|date:"F"}} <span>{{object.pub_date|date:"j, Y"}}</span></time> + </article> {% endfor %} + </div> + </main> +{%endblock%} + + <p>If you're not familiar, darktable is open source raw image developer. It's free, you can download a copy for Linux, macOS, or Windows. The <a href="https://www.darktable.org/usermanual/en/">darktable user manual</a> is very helpful if you're brand new. I also recommend <a href="https://www.youtube.com/user/audio2u">Bruce Williams' darktable videos</a>, and <a href="https://www.youtube.com/user/s7habo/videos">Boris Hajdukovic's videos</a>, which were the inspiration for what you see here.</p> + <p>I'm no expert either, so feel free to hit reply and let me know if I get something wrong.</p> diff --git a/app/lttr/templates/lttr/range_subscribe.html b/app/lttr/templates/lttr/range_subscribe.html new file mode 100644 index 0000000..e73ca73 --- /dev/null +++ b/app/lttr/templates/lttr/range_subscribe.html @@ -0,0 +1,23 @@ +{% load typogrify_tags %} +<html style="border:none !important;" dir="ltr" lang="en-US"> +<head> + <meta charset="utf-8"> + <meta http-equiv="x-ua-compatible" content="ie=edge"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <link rel="stylesheet" + href="/media/screenv10.css" + media="screen"> +</head> +<body> + <form action="" method="post" target='_parent' class="generic-form flex newsletter-subscribe">{% csrf_token %} + {% for field in form %} + <fieldset> + {{field.label_tag}} + {{field}} + </fieldset> + {% if forloop.last %}<input type="submit" name="post" class="btn" value="Subscribe" />{%endif%} + </form> + <small class="alert">{% if field.errors %}{{field.errors}}{% endif %}</small> + {%endfor%} +</body> +</html> diff --git a/app/lttr/templates/lttr/subscribe.html b/app/lttr/templates/lttr/subscribe.html new file mode 100644 index 0000000..e73ca73 --- /dev/null +++ b/app/lttr/templates/lttr/subscribe.html @@ -0,0 +1,23 @@ +{% load typogrify_tags %} +<html style="border:none !important;" dir="ltr" lang="en-US"> +<head> + <meta charset="utf-8"> + <meta http-equiv="x-ua-compatible" content="ie=edge"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <link rel="stylesheet" + href="/media/screenv10.css" + media="screen"> +</head> +<body> + <form action="" method="post" target='_parent' class="generic-form flex newsletter-subscribe">{% csrf_token %} + {% for field in form %} + <fieldset> + {{field.label_tag}} + {{field}} + </fieldset> + {% if forloop.last %}<input type="submit" name="post" class="btn" value="Subscribe" />{%endif%} + </form> + <small class="alert">{% if field.errors %}{{field.errors}}{% endif %}</small> + {%endfor%} +</body> +</html> diff --git a/app/lttr/templates/lttr/subscribed.html b/app/lttr/templates/lttr/subscribed.html new file mode 100644 index 0000000..43278cf --- /dev/null +++ b/app/lttr/templates/lttr/subscribed.html @@ -0,0 +1,24 @@ +{% extends 'base.html' %} +{% load typogrify_tags %} + +{% block pagetitle %}Thanks for subscribing! | luxagraf.net {% endblock %} +{% block metadescription %}Thank you, I appreciate you joining the club{% endblock %} + +{% block primary %} + <nav class="breadcrumbs" itemscope itemtype="http://schema.org/BreadcrumbList"> + <span class="nav-item" itemprop="item"> + <a href="/" itemprop="name">Home</a> + <meta itemprop="position" content="1" /> + </span> + <span class="nav-item" itemprop="item"> + <span itemprop="name">lttr</span> + <meta itemprop="position" content="2" /> + </span> + </nav> + <main role="main" id="essay-archive" class="archive-wrapper"> + <div class="archive-intro"> + <h2>Thanks, You're Almost There.</h2> + <p><b>Check your email for a link to confirm your subscription</b></p> + </div> + </main> +{%endblock%} diff --git a/app/lttr/templates/lttr/unsubscribe.html b/app/lttr/templates/lttr/unsubscribe.html new file mode 100644 index 0000000..993ea9f --- /dev/null +++ b/app/lttr/templates/lttr/unsubscribe.html @@ -0,0 +1,24 @@ +{% extends 'base.html' %} +{% load typogrify_tags %} + +{% block pagetitle %}Luxagraf | Friends of a Long Year {% endblock %} +{% block metadescription %}An infrequesnt mailing list about travel, photography, tools, walking, the natural world and other ephemera.{% endblock %} + +{% block primary %} +<nav class="breadcrumbs" itemscope="" itemtype="http://schema.org/BreadcrumbList"> + <span class="nav-item" itemprop="item"> + <a href="/" itemprop="name">Home</a> + <meta itemprop="position" content="1"> + </span> + <span class="nav-item" itemprop="item"> + <span itemprop="name">Unsubscribe</span> + <meta itemprop="position" content="2"> + </span> +</nav> + <main role="main" id="essay-archive" class="essay-archive archive-list"> + <div class="essay-intro"> + <h2>You're unsubscribed, so long friend</h2> + <p>If you clicked by mistake you can always <a href="{% url 'lttr:newsletter_activate' slug=newsletter activation_code=subscriber.activation_code %}">rejoin our merry band</a>.</p> + </div> + </main> +{%endblock%} diff --git a/app/lttr/urls.py b/app/lttr/urls.py new file mode 100644 index 0000000..abeac11 --- /dev/null +++ b/app/lttr/urls.py @@ -0,0 +1,27 @@ +from django.urls import path, re_path + +from . import views + +app_name = "lttr" + +urlpatterns = [ + path( + '<str:slug>/unsubscribe/<str:activation_code>', + views.UnsubscribeRequestView.as_view(), + name='newsletter_unsubscribe' + ), + path( + r'<str:slug>/subscribe/', + views.NewsletterSubscribeView.as_view(), + name="subscribe" + ), + path( + '<str:slug>/activate/<str:activation_code>/', + views.ConfirmSubscriptionView.as_view(), name='newsletter_activate' + ), + path( + r'subscribed/', + views.NewsletterSubscribedView.as_view(), + name="subscribed" + ), +] diff --git a/app/lttr/validators.py b/app/lttr/validators.py new file mode 100644 index 0000000..a6355bf --- /dev/null +++ b/app/lttr/validators.py @@ -0,0 +1,19 @@ +from django.contrib.auth import get_user_model +from django.forms.utils import ValidationError +from django.utils.translation import ugettext_lazy as _ + + +def validate_email_nouser(email): + """ + Check if the email address does not belong to an existing user. + """ + # Check whether we should be subscribed to as a user + #User = get_user_model() + + #if User.objects.filter(email__exact=email).exists(): + # raise ValidationError(_( + # "The e-mail address '%(email)s' belongs to a user with an " + # "account on this site. Please log in as that user " + # "and try again." + # ) % {'email': email}) + pass diff --git a/app/lttr/views.py b/app/lttr/views.py new file mode 100644 index 0000000..d17a609 --- /dev/null +++ b/app/lttr/views.py @@ -0,0 +1,108 @@ +import socket +from django.views.generic import ListView, CreateView, TemplateView, FormView +from django.views.generic.detail import DetailView +from django.views.generic.dates import YearArchiveView, MonthArchiveView +from django.contrib.syndication.views import Feed +from django.template.response import TemplateResponse +from django.contrib.auth import get_user_model +from django.db.models import Q +from django.shortcuts import get_object_or_404, redirect +from django.conf import settings +from django.urls import reverse, reverse_lazy + +from utils.views import PaginatedListView, LuxDetailView + +from smtplib import SMTPException +from .models import Subscriber, Newsletter +from .forms import SubscribeRequestForm, UpdateForm + +ACTIONS = ('subscribe', 'unsubscribe', 'update') + + +class NewsletterSubscribedView(TemplateView): + template_name = "lttr/subscribed.html" + + +class NewsletterOptionsView(ListView): + model = Newsletter + + +class ConfirmSubscriptionView(DetailView): + model = Subscriber + template_name = "lttr/confirm_activate.html" + + def get_object(self): + obj = Subscriber.objects.get(newsletter__slug=self.kwargs['slug'], activation_code=self.kwargs['activation_code']) + if obj.subscribed is False: + obj.update('subscribe') + return obj + + def get_context_data(self, **kwargs): + context = super(ConfirmSubscriptionView, self).get_context_data(**kwargs) + context['newsletter'] = self.kwargs['slug'] + return context + + +class NewsletterSubscribeView(CreateView): + """ + Return a subscribe form for iframe embedding + """ + model = Subscriber + form_class = SubscribeRequestForm + action = 'subscribe' + slug = None + + + def get_template_names(self): + return ["lttr/%s_subscribe.html" % self.slug, 'lttr/subscribe.html'] + + def get_form_kwargs(self): + kwargs = super(NewsletterSubscribeView, self).get_form_kwargs() + try: + self.slug = self.kwargs['slug'] + except: + pass + nl = Newsletter.objects.get(slug=self.slug) + kwargs['newsletter'] = nl + return kwargs + + def get_success_url(self): + return reverse_lazy('lttr:subscribed') + + def form_valid(self, form, **kwargs): + form.instance.user, created = get_user_model().objects.get_or_create( + email=form.cleaned_data['email_field'], + username=form.cleaned_data['email_field'] + ) + self.object = form.save() + try: + self.object.send_activation_email(action=self.action) + + except (SMTPException, socket.error) as e: + print(e) + self.error = True + + # Although form was valid there was error while sending email, + # so stay at the same url. + return super(NewsletterSubscribeView, self).form_invalid(form) + return super(NewsletterSubscribeView, self).form_valid(form) + + +class UnsubscribeRequestView(DetailView): + model = Subscriber + template_name = "lttr/unsubscribe.html" + + def get_object(self): + obj = Subscriber.objects.get(newsletter__slug=self.kwargs['slug'],activation_code=self.kwargs['activation_code']) + if obj.subscribed is True: + obj.update('unsubscribe') + return obj + + + def get_context_data(self, **kwargs): + context = super(UnsubscribeRequestView, self).get_context_data(**kwargs) + context['subscriber'] = self.get_object() + context['newsletter'] = self.kwargs['slug'] + return context + + |