Sometimes less is more

Posted by & filed under Data.

It doesn’t take much for data to start becoming a little unmanageable, especially as your data lists start to grow. In the world of email marketing there is often the scenario where data is collected from multiple independent sources which can soon develop into a situation where there are lots of unruly duplicates between lists. A decision may then have to be made about how best to prioritise these lists and make sure the combined list is unique or else unknowingly the same people may be repeatedly chosen to receive similar campaigns which, frankly, will just annoy them. Rule #13 in the “How to Manage Data for Email Marketing Guide” states: “whatever you do, don’t infuriate your target audience”. So, here is a procedure which will speed up the process of working out the number of duplicates between lists eliminating the need to do it one by one:

Step 1: Ensure your lists are stored in a SQL database with an easy to identify name

So, you have five lists each with an email, and an index on the email field:

List1
List2
List3
List4
List5

Step 2: Create a table in the same SQL database with these list names

Create a new list with an ID to allow the identification of each list through a sequential number:

create table List_Names (ID int auto_increment not null, ListName varchar(200),primary key(ID))
;

Add your list names to this table:

insert ignore into List_Names
(ListName)
select 'List1' union
select 'List2' union
select 'List3' union
select 'List4' union
select 'List5'
;

Step 3: Create the stored procedure

CREATE PROCEDURE sp_DedupeAnalysis (OUT Results varchar (200))
BEGIN

declare sqlexec varchar(10000); -- to be used to run sql statements
declare Counter int;-- to hold the number of lists
declare LoopCounter int; -- to count the number of loops for the first loop
declare LoopCounter2 int; -- to count the number of loops for the second loop
declare FieldName varchar(200); -- to hold the name of the lists across the table
declare RowName varchar(200); -- to hold the name of the lists down the table
declare ColumnID int; -- the position in the List_Names table

set ColumnID = 1; -- starting at position 1 in the List_Names table
set Counter = (select max(ID) from List_Names); -- set to the number of lists
set LoopCounter = 1; -- starting at position 1 for the first loop

-- Creating the analysis table

drop table if exists DedupeAnalysis
;
-- Write the start of the create table statement with the first column which will contain all the list names as rows

set sqlexec = concat("create table DedupeAnalysis (List varchar(200),");

-- This loop will add to the create table statement the list names barring the last one
WHILE LoopCounter < Counter DO
BEGIN

set FieldName = (select ListName from List_Names where ID = LoopCounter);
set sqlexec = concat(sqlexec,FieldName," varchar(200),");
set LoopCounter = LoopCounter+1;

END;
END WHILE;

-- This next statement will add the final column and close the create table statement
IF LoopCounter = Counter THEN
set FieldName = (select ListName from List_Names where ID = LoopCounter);
set sqlexec = concat(sqlexec,FieldName," varchar(200))");
END IF;

-- Run the complete create table statement and DedupeAnalysis will exist
select sqlexec into @Results;
prepare stmt from @Results;
execute stmt;

-- Add in the rows with the list names from the List_Names table created earlier
insert into DedupeAnalysis
(List)
select ListName from List_Names
;

-- Create the loops which will count down the rows for each List column utilising a nested loop within

set LoopCounter = 1; -- reset the LoopCounter to 1

WHILE ColumnID <= Counter DO -- For 1 to the maximum number of rows (5 in this case)
BEGIN
set FieldName = (select ListName from List_Names where ID = LoopCounter); -- select the current List Name for the horizontal list names
set LoopCounter2 = 1; -- Reset the second loop counter to point to the first list name
WHILE LoopCounter2 <= Counter DO
BEGIN
set RowName = (select ListName from List_Names where ID = LoopCounter2); -- select the current List Name for the vertical list names

-- Create the SQL query that will count the matching horizontal list name with the current vertical one and execute it
set sqlexec = concat("update DedupeAnalysis set ",FieldName," = (select count(*) from ",FieldName," a inner join ",RowName," b on a.email = b.email) where List = '",RowName,"'");
select sqlexec into @Results;
prepare stmt from @Results;
execute stmt;

-- increase the ID for the vertical List Name by 1 and the loop will start again until it completes
set LoopCounter2 = LoopCounter2+1;
END;
END WHILE;

-- increase the horizontal List Name by 1 and the loop will start again until it completes
set LoopCounter = LoopCounter+1;
set ColumnID = ColumnID+1;

END;
END WHILE;

Step 4: Run and analyse the results

CALL sp_DedupeAnalysis(@Results)
;
select * from DedupeAnalysis
;

DedupeAnalysis

So, we can see the list sizes down the diagonal middle where the list matches with itself, and then what we ultimately have is a nice lookup table to see how many records match between each individual list. From this we can then determine such things as de-duplication priorities and soon whip the data into shape.

Whatever you do, don’t call it spam!

Posted by & filed under Deliverability.

Sometimes our business model means we can’t always send the greatest and best emails in the world, sometimes we have to work with emails that don’t quite meet the lofty ideals we’d like to adhere to here at Warrior HQ, that’s fine, we understand, we wouldn’t be where we are if we didn’t. We also understand that sometimes these kinds of emails can get lost on their journey from message platform to inbox and end up in your recipients junk folder or even bounced; the good news is there are lots of ways to help avoid these issues and get your message into the inbox on a consistent basis, ways like:

Selecting your data wisely
Select your lists based on user activity, anyone who hasn’t opened or clicked on one of your emails in the last 12 months shouldn’t be part of your regular mailings, consider putting these inactive users into some kind of ‘re-engagement’ program, once a week or even once a fortnight send them your best, nicest, most enticing email to re-engage them and get them back amongst your active pool.

Send Sensibly, Send Consistently
Just because you have half a million emails available to send doesn’t mean you should send to half a million, and it certainly doesn’t mean you should send half a million in 5 minutes either, ISP’s will see this traffic pouring into their inboxes and won’t hesitate to throttle, filter or even bounce your message if they want. Better to spread your sends out over a longer period of time and increase/decrease depending on how you’re delivering, the aim is to have a gradual increase to a maintainable level per day with no sudden peaks or drops in your sends.

You Must Hear What I Have To Say!
When was the last time a shop assistant shouted their offers at you? Was there a sale on that was so good they had to scream right in your face about it? I’m guessing that’s never happened but we see shouting in emails all too often and it’s really not needed. The overuse of exclamation marks falls into this territory too, 1 or 2 is fine, but to end every sentence with one is going overboard. I’m sure some people think that these things are necessary to highlight aspects of their message but really all they achieve is an increase in your spamscore, which could increase the chances of your message being filtered.

Remember, this advice will only help you get your emails into consumers inboxes and are by no means a delivery silver bullet, and there are a whole bunch of other things to consider if you want to generate engagement from your campaigns, things which we Warriors are more than happy to talk about.

A character building experience

Posted by & filed under Data.

Database character sets can cause a whole lot of problems if you are trying to compare to mismatched data sets. I encountered this problem when a migration to a new server resulted in a collection of databases with different collations, and within those, tables with a whole array of wild collations including latin1_swedish_ci and utf8_polish_ci. Heaven knows how those two came about. This issue first came to my attention when I left a MySQL script running over night, only to be confronted with it still running some 15 hours later when I returned to work. What the…? It seemed to be stuck on a simple left join query and with some impressive investigative skills if I do say so myself (i.e. googling it) I soon determined that mismatched character sets could be and in fact were the issue.

So, if you find yourself in a similar position with lots of tables that are no longer compatible with each other you are going to want a quick fix. First things first, you can change the collations of all your databases to match each other, for example, by setting them to a character set of say UTF-8 with a collation of utf8_unicode_ci:

create temporary table DatabaseCharacterSets (DatabaseName varchar(1000));

-- Select all your database names into a temporary table with the exception of the default information_schema database

insert into DatabaseCharacterSets
select SCHEMA_NAME as DatabaseName from information_schema.SCHEMATA where SCHEMA_NAME <>'information_schema';

-- Turn your database names into full sql queries

update DatabaseCharacterSets
set DatabaseName = concat('alter DATABASE ',DatabaseName,' character set utf8 collate utf8_unicode_ci;');

--Select all records which you can then copy and paste in order to run all the queries

select * from DatabaseCharacterSets;

DatabaseChanger

Then, any new table that gets created will be of the character set utf8 with the utf8_unicode_ci collation. But what about the tables already in the database, do they get changed as well? Not on your Nellie. Nope, you’ll have to go through the whole process again to update all the existing tables to match. In much the same way as for the database amendments, the easiest way is to open a connection to each database in turn and run the following queries:


create temporary table Table_CharacterSets (TableName varchar(1000))
;
insert into Table_CharacterSets
select Table_Name as TableName from information_schema.TABLES where TABLE_SCHEMA = 'Database1'
;
update Table_CharacterSets
set TableName = concat('alter table ',TableName,' convert to character set utf8 collate utf8_unicode_ci;')
;
select * from Table_CharacterSets
;

Then once again the output from Table_CharacterSets will act as a straightforward script to adjust all your tables:


TableChanger

So all in all, this whole procedure could be a mere 5 minute job for what may have looked like a never-ending nightmare. So, did my 15 hour script work after all that? It took just 2.14 seconds to run the offending query. Go figure.

Do you suffer from long term memory loss?

Posted by & filed under Data.

Computer memory can be a contentious thing – on the one hand when you’re talking about disk storage it is easy, a kilobyte is just 1,000 bytes, a megabyte is 1,000 kilobytes and so on which makes the maths easy. But, on the other hand when you’re talking about real and virtual storage then a kilobyte is actually 1,024 bytes, a megabyte is 1,024 kilobytes etc which makes the maths just that bit harder. So, if you procure yourself a storage device say with 4GB of storage but you see your capacity is actually only 3.67GB you will soon notice the distinction. So, 4GB when referring to virtual storage = 4,294,967,296 bytes to be precise, but when manufacturers refer to disk storage, for them 4GB is approximately 4,000,000,000 bytes which is actually 3.72GB (so the manufacturers simply rounded up from 3,947,823,104 bytes – diddled again).

So, what situations are there when you may need to convert your virtual memory? Perhaps you need to work out how much real disk storage space you have left or may need for future events, what data allowance you have left, or even work out your bandwidth usage. One way is to just make yourself a simple converter using JavaScript.

There are four steps to this:

1. Create a form to capture and display the conversion results
2. Create a function to convert the results
3. Create a function to limit the decimal places for the results
4. Create a function to clear the form


The Maths:

1 kilobyte = 1,024 bytes
1 megabyte = 1,024 kilobytes
1 gigabyte = 1,024 megabytes
1 terabyte = 1,024 gigabytes

If you have 1,024 bytes, to work out the number of kilobytes it must be divided by 1,024 equalling 1.

If you have 1 kilobyte it must be multiplied by 1,024 to work out the number of bytes equalling 1,024.

So, if you have 1 megabyte, to work out the number of bytes it must first be multiplied by 1,024 to give the number of kilobytes and then by a further 1,024 to give the number of bytes.

These basic rules can then be utilised via JavaScript to create a conversion function for the increasing groups.

The Code:


<HTML>
<HEAD>
<SCRIPT LANGUAGE="JavaScript">

//This function will convert a number to the user defined number of decimal places (the DP parameter)

function DecimalPlaces(Number, DP) {
var FixedOutput = Math.round(Number*Math.pow(10,DP))/Math.pow(10,DP);
return FixedOutput;
}

//This function will pull in all the values from the form and add them to variables, then test each variable and if it contains a value it will run the conversion on the other groups and display them back in the form with up to 6 decimal places.

function run(form1)
{
var Byte = document.form1.Byte.value;
var Kilobyte = document.form1.Kilobyte.value;
var Megabyte = document.form1.Megabyte.value;
var Gigabyte = document.form1.Gigabyte.value;
var Terabyte = document.form1.Terabyte.value;

if (Byte!='')
{
document.form1.Kilobyte.value = DecimalPlaces(Byte/1024,6);
document.form1.Megabyte.value = DecimalPlaces(Byte/(1024*1024),6);
document.form1.Gigabyte.value = DecimalPlaces(Byte/(1024*1024*1024),6);
document.form1.Terabyte.value = DecimalPlaces(Byte/(1024*1024*1024*1024),6);
}

if (Kilobyte!='')
{
document.form1.Byte.value = DecimalPlaces(Kilobyte*1024,6);
document.form1.Megabyte.value = DecimalPlaces(Kilobyte/(1024),6);
document.form1.Gigabyte.value = DecimalPlaces(Kilobyte/(1024*1024),6);
document.form1.Terabyte.value = DecimalPlaces(Kilobyte/(1024*1024*1024),6);
}

if (Megabyte!='')
{
document.form1.Byte.value = DecimalPlaces(Megabyte*1024*1024,6);
document.form1.Kilobyte.value = DecimalPlaces(Megabyte*1024,6);
document.form1.Gigabyte.value = DecimalPlaces(Megabyte/(1024),6);
document.form1.Terabyte.value = DecimalPlaces(Megabyte/(1024*1024),6);
}

if (Gigabyte!='')
{
document.form1.Byte.value = DecimalPlaces(Gigabyte*1024*1024*1024,6);
document.form1.Kilobyte.value = DecimalPlaces(Gigabyte*1024*1024,6);
document.form1.Megabyte.value = DecimalPlaces(Gigabyte*1024,6);
document.form1.Terabyte.value = DecimalPlaces(Gigabyte/(1024),6);
}

if (Terabyte!='')
{
document.form1.Byte.value = DecimalPlaces(Terabyte*1024*1024*1024*1024,6);
document.form1.Kilobyte.value = DecimalPlaces(Terabyte*1024*1024*1024,6);
document.form1.Megabyte.value = DecimalPlaces(Terabyte*1024*1024,6);
document.form1.Gigabyte.value = DecimalPlaces(Terabyte*1024,6);
}

}

</SCRIPT>

</HEAD>

<BODY bgcolor="#E6E6FA">
<H2 align = "center">Memory Converter</H2>

<!--
Create the form in a table labelling the names to match the run function
-->

<P>
<div align = "center">
<form name="form1" bgcolor = "white">
<table border = "1">
<tr><td width = "80">Byte: </td><td><input type="text" name="Byte" size = "25"/></td></tr>
<tr><td width = "80">Kilobyte: </td><td> <input type="text" name="Kilobyte" size = "25"/></td></tr>
<tr><td width = "80">Megabyte: </td><td> <input type="text" name="Megabyte" size = "25"/></td></tr>
<tr><td width = "80">Gigabyte: </td><td> <input type="text" name="Gigabyte" size = "25"/></td></tr>
<tr><td width = "80">Terabyte: </td><td> <input type="text" name="Terabyte" size = "25"/></td></tr>
</table>
<br><br>
<input type="button" Value="Calculate Memory Conversion" onclick="run(this.form);"/>
<br><br>
<input type="button" Value="Clear" onclick="this.form.reset()"/>
</form>
</div>
</P>

</BODY>
</HTML>


Let’s see it in action:

The Before:

The After:

If you’re really wild you can extend this to include Petabytes, Exabytes, Zettabytes, Yottabytes, Brontobytes and Geopbytes but these are an obscene amount of memory and most people will probably struggle to fill up 1TB in their lifetime!

I couldn’t say it better myself

Posted by & filed under email marketing.

I was doing some research to dispel the rumours that the new kids on the block like to spread. I remember being the new boy in 1999 and saying the same things about DM so I can’t blame them. Email has had to fight of the RSS fiend, and now is under attack by social media but that has been true for some time now and the truth is it is still the preferred medium for marketers to reach their customers with news and offers. More importantly it still seems to be the preferred method for consumers to receive these marketing messages.

While doing my research I came across this infographic from Litmus and thought, I actually can’t say this better than them so with due reference to the creators of this I present you the evidence that email is alive and well and delivering results from an inbox near you!

Coming to an Inbox near you!

Posted by & filed under email marketing.

HTML5 has changed email marketing forever! Bold statement but let’s take a look at the evidence and let you decide for yourself. Prior to HTML5 anything “clever” you did on your website was done using some kind of scripting language like javascript, and anything like that the ISP’s stripped out of your emails because they could be seen to be unsafe scripts and subsequently removing them was seen to be the safest option for the ISP and your customers.

HTML5 has changed all that, things that sounded like great ideas but which in reality were holding images that linked back to the great idea on a website are now possible and if they are possible guess what? An early adopter, a clever marketer is going to grab hold of this market advantage and send you an email any day now exploiting these new fangled options! So let’s look at a few examples from Movable Ink which highlight the possibilities.

Firstly, Video in email, yes it’s finally here, for years and years we have been saying “you can’t embed video in email”. Although that wasn’t entirely true it was for the majority of the browsers that read email that it made it a no go zone, but now you can!

Secondly, Our friend Responsive email or Mobile optimised email, @warrior_josh has been beating this drum for over a year now and mixing my metaphors we are finally getting some traction. Having a mobile site has become of major importance to marketers whether they be retailers or b2b. The proportion of traffic visiting their sites from a handheld device has grown dramatically, some of our clients are seeing over 50% of their click through traffic coming from mobile devices so user experience in email needs to be as good as it is site-side and that’s why it imperative to start building your emails in a mobile optimised manner. Make it easy for me to interact with you and the chances are I will!

Thirdly, the concept of a truly live email that changes in the inbox as something change on your website, or Social Network environment is a little mindblowing but as you can see from the below example it is now possible to feed a live ticker tape into your email featuring latest products, tweets, competition entries, pretty much anything happening in your webosphere can now be pulled dynamically into your email.

Fourthly, Countdown clocks or Stock tickers, add a sense of urgency to your mailing by having either a countdown clock counting the hours till a sale starts or the hours left in a particular sale or a stock ticker counting the number of products left in a particular sale, much like QVC do on their TV sales.

Next, and this ties in with three and four above but new product launches can now have that sense of mystery about them by having the email delivered 24 hours before a product launch, showing the product in shadow and teasing the recipient with a countdown clock saying check back here at 8:00 AM tomorrow to see the new iPhone 6 or whatever the product might be. So the user see’s one thing at 07:59:59 and see’s the product itself unveiled at 08:00.

Finally, Split Run testing, it’s something we have all discussed and most of us have done to a various extent and with various amounts of success. However, it is now possible to send the campaign out to your whole data set with 2 or 3 different creative treatments and in real-time change the whole email in the inbox of every single recipient to the one which is performing best in terms of click through or ROI after they have all been delivered. Even if I have opened your email and seen the worst performing one, the next time I return to it I see the one with the highest clicks or ROI. That truly is Movable Ink!

The possibilities for using these new techniques and technologies are vast, and somewhere out there one of your competitors is looking to steal and edge by being first to market with the new coolness. If you want to talk to a warrior about how we can help you with this stuff then give us a call, 0208 948 4699 or send us an email or visit us at TFM&A on the 26th and 27th of February at Earls Court.

Blocks are not just found in Tetris

Posted by & filed under Data.

Sometimes webmail providers (WMPs) just won’t play ball and will block emails from getting delivered to the inbox for an emailing campaign. Time, that great healer, can allow for any affected IP’s reputation to recover enough to remove the block and with a sensible plan to warm it up again these blocks can be avoided in the future, but options are often limited if you need to get a time sensitive campaign out so more often than not the only thing for it to is to not send to the blocked domains at all to give the campaign the best chance. Some of the most problematic WMPs are Hotmail, Yahoo and Gmail who are strict in their quests to protect their users. But how do you actually know which domains belong to these WMPs to make sure you remove all the potentially troublesome emails from your send list?

Take hotmail for example – it is probably a well known fact that hotmail, msn and live domains are all part of the same group, but could there be other domains and if so what are they? Well a really easy way to find out, assuming you have a compliant email platform, is if you can gain access to the mail server details of each of your emails (a feature that the Inbox Warriors’ platform provides) and from that you can extract a list of all the domains that match to specific mail servers through SQL and create yourself some lookup tables which will save you a lot of time in the event of such data emergencies.

Step 1: Download your Email and Mail Server information

Hopefully, you can get hold of something like the following which you can upload into an SQL table e.g. named MailServer:

Step 2: Create your Lookup Tables

Firstly, you can extract the domain of each email by adding an extra Domain column and then updating it to contain just the part of the email following the @ sign:

alter table MailServer add Domain varchar(200);

update MailServer set Domain = substring(email FROM POSITION('@' IN email)+1);

Then you can create your lookup tables for whichever Mail Servers you want e.g.:

create table MailServer_Hotmail as
select distinct domain from MailServer where MailServer like ‘%hotmail%’;

create table MailServer_Gmail as
select distinct domain from MailServer where MailServer like ‘%google%’;

create table MailServer_Yahoo as
select distinct domain from MailServer where MailServer like ‘%yahoo%’;

In the analysis of just some 600K records there were 99 different domains for Yahoo, 125 for Hotmail and a whopping great 3,820 for Gmail. Try committing all those to memory. Here’s a quick look at the top 20 domains for each – some might surprise you:

So you now find yourself in the position that if you ever need to remove, say, all your hotmail records from your data or perhaps separate them out for targeting purposes, then rather than having to remember hundreds of different domains and probably missing more than a few, you can simply match back each domain to your lookup tables which is a neater and safer procedure.

A new broom

Posted by & filed under email marketing.

So the silly season is over and we are firmly back in the swing of the new year. And with new year comes new opportunities and new ideas. So what is there from the warriors? Well this year we start with 2 new toys from the warriors. Well, one brand new and one that we launched towards the end of last year and are really looking to promote in 2013.

Double prompt is our new basket abandonment integration tool which allows for very smart triggered or series of triggered messages based on user basket abandonment in real time. Simple to set up, and with only a 3 month minimum commitment the cost risk is minimal. Double Prompt enables the marketer to engage abandoned cart holders with relevant, timely 1-2-1 messages which actually drive results. Double prompt also provides a realtime report on Abandoned Value, Recently Abandoned Carts, Recovered Value, Sent Messages, Page Views, Sessions, Products, Abandons, Recoveries and Active Cart Values. Double prompt is also system agnostic and can be configured to plug into any of the major ESP’s.

The second of our exciting initiatives is the launch of the newest version of IBWMAIL. With a host of cool new features including a new template library, with some free standard templates built in and with a simple drag and drop WYSIWYG for editing these it makes for idiot proof creation of great emails.

The changes around scheduling are also pretty neat. There is a new wave type feature on the multi send element, plus standard sends can be throttled by you at the rate you want emails to go out. Coolest of all is the new split testing feature, upgraded from 2 possible splits to as many as you like. The winning split is automatically sent to the rest of your data at a time scheduled by you and using a number of new criteria, so the winner can be chosen on opens or clicks or number of conversions or value of conversions or you could just use this feature to send the same email campaign out randomly to your whole database using a range of different subject lines with no winner.

Having played with the new features in testing I can tell you they make for some simple but effective improvements on an already pretty hot platform.

For more info contact Mike

Keep your integrity with normalization

Posted by & filed under Data.

From an email marketing data management point of view, one problem I have occasionally found with a database built up from multiple sources is that each record is assigned a unique ID, but this doesn’t necessarily prevent the duplication of such things as email (if there was no de-duplication procedure in place), so consequently I could receive a 500K subset of a database intended for an email marketing campaign, out of which only 490K of the records are distinct emails. This leads us on to the important issue of planning the structure of databases properly prior to adding data in order to prevent such mishaps as this from occurring.

So, if your data is in a “flat file” format, meaning all you’ve got is something like email, name, and address and no information with potentially multiple instances then you won’t need to worry about normalizing your data (as long as you set the primary key of your database table to a unique identifier, in this case email to ensure the uniqueness of your data). If however you are in a position where you are, for example, keeping track of customer orders, so you have a customer base, a product base and all the orders each customer has made, then you’re going to find yourself in the thralls of a “many-to-many” relationship and this is where normalizing your data will be most advantageous.

So what are we trying to achieve with data normalization? Basically, at the end of the process we want to be left with firstly tables that only contain relevant attributes, i.e. a customer file will have no mention of orders or products, only customer information, so data is only stored once to save space and secondly, ensuring that we fully understand the relationship between our entities, e.g. our customer and product files, so we can select just the relevant attributes between the two that will allow us to link them. I won’t bore you with the details, but using an Entity-Relationship model to analyse the relationship between our data sources is highly beneficial – as in the below diagram we can see that there is a many-to-many relationship between our customers and our products i.e. a product can be bought by many different customers, and customers can buy many different products:

We can see in our Customer_Table that there is only information related directly to our customer, along with a unique Customer_ID which can be used to identify each customer. Likewise, with our Product_Table only information related to the product is stored, again along with an identifying Product_ID. It is the Order_Table which is of most importance, as this joins the Customer_Table with the Product_Table where there is a match on an order, so we can combine whatever information we want from any of the tables by simply matching the two IDs together. This Order_Table also stores each individual order (identified by an Order_ID) along with the date and quantity ordered of each product so a complete order can be pulled from these three tables. By setting the primary key to all three IDs this means a customer can be in there with more than 1 order, a product can appear on more than 1 orders, an order can have multiple products, but for 1 order there can only be 1 customer and a product can only appear once.

A demonstration of how information can be extracted by using inner joins:

Example 1 – Extract all relevant information about Order #11

select a.Firstname, a.Lastname,b.Order_ID,b.Order_Date, c.Name, c.Price, b.Product_Quantity as Quantity, b.Product_Quantity * c.Price as TotalPrice
from Customer_Table a
inner join Order_Table b on a.Customer_ID = b.Customer_ID
inner join Products_Table c on b.Product_ID = c.Product_ID
where b.Order_ID = 11
go

Example 2 – Extract all relevant information about Customer #4

select a.Firstname, a.Lastname,b.Order_ID,b.Order_Date, c.Name, c.Price, b.Product_Quantity as Quantity, b.Product_Quantity * c.Price as TotalPrice
from Customer_Table a
inner join Order_Table b on a.Customer_ID = b.Customer_ID
inner join Products_Table c on b.Product_ID = c.Product_ID
where a.Customer_ID = 4
order by Order_ID
go

So we have access to every single attribute from every table, simply by matching the Customer_Table to the Products_Table via the Order_Table and can pick and choose them as we please depending on the data analysis requirements.

Pros / Cons

+ Data Redundancy is removed saving disk space
+ Referential Integrity (making sure there is something to join tables on) can be enforced
+ Clustered Indexes will improve data access speeds
+ Updating or Deleting records should have no impact on other tables as it is localised to that specific table, and therefore there will be no need to ever update multiple tables which could lead to integrity issues
- The more tables you have, the more inner joins required to retrieve all your required information which makes for more complicated and slower queries

Every database will be different, and you need to work out the data requirements weighed against the pros and cons to determine the best structure for your database, but normalizing it is more often than not the best way to ensure the maintainable integrity of your data.